Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/branch-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
# test-results/ holds the per-failure trace.zip files referenced by
# playwright.config.ts's `trace: "retain-on-failure"`. They're
# separate from the HTML report, so include both paths.
path: |
playwright-report/
test-results/
retention-days: 7

deploy:
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
# test-results/ holds the per-failure trace.zip files referenced by
# playwright.config.ts's `trace: "retain-on-failure"`. They're
# separate from the HTML report, so include both paths.
path: |
playwright-report/
test-results/
retention-days: 7

deploy:
Expand Down
6 changes: 5 additions & 1 deletion docs/browser-storage-spec.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Browser Storage Specification
# Browser Storage Specification (SUPERSEDED)

> **This spec is historical.** Tracks and tasks are no longer stored in IndexedDB — see migration `0008_user_files.sql` and `web/workers/competition-api/src/routes/user-files.ts`. Authenticated users now upload to R2 (tracks) and D1 (tasks) via `/api/user/...`. Map annotations are scoped to a (user, track) pair in D1 so they're visible to anyone viewing the track via `/analysis.html?u={username}&track={track_id}`. Anonymous users still get in-memory analysis but nothing persists. The legacy IndexedDB stores are dropped by a one-time client-side migration in `web/frontend/src/auth/user-files-migration.ts`.
>
> The rest of this document is preserved for context.

## Overview

Expand Down
8 changes: 4 additions & 4 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ bun run wrangler2 d1 execute taskscore-auth --remote --file=web/workers/auth-api

## Account Deletion

`POST /api/auth/delete-account` deletes the `user` row from D1. CASCADE foreign keys automatically clean up `session` and `account` rows. The frontend also clears `localStorage` and deletes the `glidecomp` IndexedDB database (which stores tracks and tasks).
`POST /api/auth/delete-account` deletes the `user` row from D1. CASCADE foreign keys automatically clean up `session`, `account`, `user_preferences`, `user_track`, `user_task`, and `user_annotation` rows. Before deleting the user row, the handler lists+deletes every R2 object under `u/{userId}/` so per-user track payloads don't outlive the account. The frontend also clears `localStorage` and deletes any leftover `glidecomp` IndexedDB database.

### Future storage checklist

When adding new user data storage, update the delete-account endpoint in `web/workers/auth-api/src/index.ts` to clean up:

- **R2 buckets:** Delete all objects under the user's prefix (e.g. `tracks/{userId}/`, `tasks/{userId}/`)
- **New D1 tables:** Add `ON DELETE CASCADE` FK constraints to `userId`, or delete manually before the user row
- **External services:** Revoke tokens or delete data before the user row is removed
- **R2 buckets:** Delete all objects under the user's prefix. `u/{userId}/...` is wired up (covers user IGC tracks); add new prefixes here if you introduce more user-owned blobs.
- **New D1 tables:** Add `ON DELETE CASCADE` FK constraints to `userId`, or delete manually before the user row.
- **External services:** Revoke tokens or delete data before the user row is removed.

## CI Deployment (GitHub Actions)

Expand Down
14 changes: 12 additions & 2 deletions docs/system-architecture-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,22 @@ RESTful API for frontend operations and admin functions.

Object storage for IGC track log files and email archives.

**Structure:**
**Structure (live):**
```
/igc/{sha256}.igc # Content-addressed IGC storage
c/{compId}/t/{taskId}/{compPilotId}.igc # Competition tracks (gzipped)
u/{userId}/track/{sha256}.igc.gz # User-owned tracks (gzipped)
```

**Structure (future, email submission):**
```
/igc/{sha256}.igc # Content-addressed IGC storage (email pipeline)
/emails/{timestamp}-{from}.eml
```

Per-user tracks are namespaced under `u/{userId}/` so the auth-api delete-account flow can purge a user's entire R2 footprint with a prefixed list+delete. Cross-user dedup was rejected to keep cascade-delete trivial (storage is cheap). Within a user's namespace tracks are still content-addressed by SHA-256, so re-uploading the same file from another device is idempotent.

User-owned XCTSK tasks live in D1 (`user_task.xctsk_json`), not R2 — they're tiny (≤32 KB) and benefit from row-level transactions during account deletion.

**Access:**
- Public read access for viewing/downloading flight logs
- Private access for email archives (admin only)
Expand Down
11 changes: 10 additions & 1 deletion e2e/comp-creation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ test("dev login, onboarding, create competition and task", async ({ page }) => {
// The "New Competition" button should be visible for authenticated users
await expect(page.locator("#create-comp-btn")).toBeVisible();

// comp.ts attaches the dialog-opening click handler only after loadComps()
// resolves — wait for the network to settle so the listener is in place
// before we click. Without this, fast CI sends the click before init()
// finishes and the dialog never opens.
await page.waitForLoadState("networkidle");

// Step 5: Create a competition
await page.click("#create-comp-btn");
await page.waitForSelector("dialog#create-comp-dialog[open]");
Expand All @@ -65,8 +71,11 @@ test("dev login, onboarding, create competition and task", async ({ page }) => {
await page.check("#comp-test");
await page.click("#create-submit-btn");

// Should navigate to the competition detail page
// Should navigate to the competition detail page. Wait for /api/comp/:id
// (and the parallel auth+preferences calls) to settle so comp-detail.ts has
// had a chance to populate the title before we assert.
await page.waitForURL("**/comp/*");
await page.waitForLoadState("networkidle");
await expect(page.locator("#comp-title")).toHaveText("E2E Test Competition");

// Step 6: Create a task
Expand Down
183 changes: 183 additions & 0 deletions e2e/user-files-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { test, expect, type Page, type APIRequestContext } from "@playwright/test";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const SAMPLE_IGC = resolve(
__dirname,
"..",
"web/samples/comps/corryong-cup-2026-t1/lamb_18239_050126.igc"
);
const SAMPLE_XCTSK = resolve(
__dirname,
"..",
"web/samples/comps/corryong-cup-2026-t1/task.xctsk"
);

interface TestUser {
name: string;
email: string;
username: string;
}

function newTestUser(prefix: string): TestUser {
const suffix = String(Date.now()).slice(-6) + Math.floor(Math.random() * 100);
return {
name: `E2E ${prefix} Pilot`,
email: `e2e-${prefix}-${suffix}@test.local`,
username: `e2e-${prefix}-${suffix}`.slice(0, 20),
};
}

/**
* Dev-login + onboarding so the page is parked at /u/<username>/ with a session
* cookie attached. Returns the chosen username so callers can build public URLs.
*/
async function signInAndOnboard(
request: APIRequestContext,
page: Page,
user: TestUser
): Promise<void> {
const loginRes = await request.post("/api/auth/dev-login", {
data: { name: user.name, email: user.email },
});
if (!loginRes.ok()) {
throw new Error(
`dev-login failed for ${user.email}: ${loginRes.status()} ${await loginRes.text()}`
);
}
const setCookie = loginRes.headers()["set-cookie"];
const match = setCookie?.match(/better-auth\.session_token=([^;]+)/);
if (!match) throw new Error("dev-login response missing session cookie");
await page.context().addCookies([
{
name: "better-auth.session_token",
value: match[1],
domain: "localhost",
path: "/",
},
]);

await page.goto("/onboarding.html");
await page.fill("#username", user.username);
await page.click("#onboarding-submit");
await page.waitForURL(`**/u/${user.username}/*`);
// dashboard.ts attaches the file-input change listeners only after storage
// init + the first refreshLists() runs; that's also when #tracks-empty stops
// being `hidden`. Sync on it so setInputFiles isn't called before the page
// is listening — without this, fast CI loses the change event.
await expect(page.locator("#tracks-empty")).toBeVisible();
}

test("upload IGC file via the My Flights dashboard", async ({ page, request }) => {
const user = newTestUser("igc");
await signInAndOnboard(request, page, user);

// Dashboard renders the tracks panel by default; empty state should be visible.
await expect(page.locator("#tracks-empty")).toBeVisible();
await expect(page.locator("#tracks-count")).toBeHidden();

// setInputFiles fires the change event the dashboard listens for. Use the
// hidden file input directly — clicking the upload-zone would open the OS
// file picker which Playwright would have to negotiate via filechooser.
await page.setInputFiles("#igc-file-input", SAMPLE_IGC);

// The list reflows after refreshLists(); look for the parsed pilot name from
// the IGC header so we know the worker round-tripped and we're not just
// seeing the filename.
const trackCard = page.locator("#tracks-list .file-card").first();
await expect(trackCard).toBeVisible();
await expect(trackCard).toContainText("Michael lamb");
await expect(page.locator("#tracks-count")).toHaveText("1");
});

test("upload XCTSK file and switch to the Tasks tab", async ({ page, request }) => {
const user = newTestUser("xctsk");
await signInAndOnboard(request, page, user);

await expect(page.locator("#tasks-empty")).toBeHidden(); // tasks panel starts hidden
await page.setInputFiles("#task-file-input", SAMPLE_XCTSK);

// dashboard.ts auto-switches to the tasks tab when only tasks were added.
await expect(page.locator("#panel-tasks")).toBeVisible();
const taskCard = page.locator("#tasks-list .file-card").first();
await expect(taskCard).toBeVisible();
// Sample task has 5 turnpoints; assert the count rather than the name so the
// assertion stays meaningful even if deriveTaskName() changes its format.
await expect(taskCard).toContainText(/turnpoint/);
await expect(page.locator("#tasks-count")).toHaveText("1");
});

test("delete an uploaded track", async ({ page, request }) => {
const user = newTestUser("del");
await signInAndOnboard(request, page, user);

await page.setInputFiles("#igc-file-input", SAMPLE_IGC);
const trackCard = page.locator("#tracks-list .file-card").first();
await expect(trackCard).toBeVisible();

// The delete button is a sibling of the download button inside the same card.
await trackCard.locator(".delete-btn").click();

await expect(page.locator("#tracks-empty")).toBeVisible();
await expect(page.locator("#tracks-list .file-card")).toHaveCount(0);
});

test("delete an uploaded task", async ({ page, request }) => {
const user = newTestUser("tdel");
await signInAndOnboard(request, page, user);

await page.setInputFiles("#task-file-input", SAMPLE_XCTSK);
const taskCard = page.locator("#tasks-list .file-card").first();
await expect(taskCard).toBeVisible();

await taskCard.locator(".delete-btn").click();

await expect(page.locator("#tasks-empty")).toBeVisible();
await expect(page.locator("#tasks-list .file-card")).toHaveCount(0);
});

test("public-link viewer can read a track uploaded by another user", async ({
browser,
request,
}) => {
// Owner uploads a track, then we open it as a separate, unauthenticated
// browser context to make sure the public-link endpoint (/api/u/…) actually
// serves it without the owner's session cookie.
const owner = newTestUser("own");
const ownerCtx = await browser.newContext();
const ownerPage = await ownerCtx.newPage();
await signInAndOnboard(request, ownerPage, owner);
await ownerPage.setInputFiles("#igc-file-input", SAMPLE_IGC);
await expect(
ownerPage.locator("#tracks-list .file-card").first()
).toBeVisible();

// Pull the track_id straight from the dashboard link — the storage layer
// stores it as a sha256 hex on the analysis href.
const trackHref = await ownerPage
.locator("#tracks-list .file-card")
.first()
.getAttribute("href");
const trackId = trackHref?.match(/storedTrack=([0-9a-f]{64})/)?.[1];
expect(trackId, `expected a sha256 track id in ${trackHref}`).toBeTruthy();
await ownerCtx.close();

// Anonymous read via the worker — no cookies. This exercises the same
// /api/u/:username/track/:sha endpoint the analysis page uses.
const anonRes = await request.get(
`/api/u/${owner.username}/track/${trackId}`
);
expect(anonRes.status()).toBe(200);
const body = await anonRes.body();
// The endpoint returns the gzipped IGC; expect the gzip magic header.
expect(body[0]).toBe(0x1f);
expect(body[1]).toBe(0x8b);

// Display name and filename are echoed in custom headers so the analysis
// page doesn't need an extra metadata round-trip.
expect(anonRes.headers()["x-filename"]).toBe("lamb_18239_050126.igc");
expect(anonRes.headers()["x-display-name"]).toContain("Michael lamb");
});
13 changes: 13 additions & 0 deletions functions/api/u/[[path]].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Pages Function that proxies all /api/u/* requests to the competition-api
* worker via a Cloudflare service binding. These are public-by-link reads of
* user-owned tracks/tasks/annotations.
*/

interface Env {
COMPETITION_API: Fetcher;
}

export const onRequest: PagesFunction<Env> = async (context) => {
return context.env.COMPETITION_API.fetch(context.request);
};
12 changes: 12 additions & 0 deletions functions/api/user/[[path]].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Pages Function that proxies all /api/user/* requests to the competition-api
* worker via a Cloudflare service binding.
*/

interface Env {
COMPETITION_API: Fetcher;
}

export const onRequest: PagesFunction<Env> = async (context) => {
return context.env.COMPETITION_API.fetch(context.request);
};
11 changes: 11 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ export default defineConfig({
testDir: "./e2e",
timeout: 60_000,
retries: process.env.CI ? 1 : 0,
// Force sequential runs in CI. With 2 workers, the comp-creation +
// user-files tests overlap on the shared local D1 and intermittently
// race a 500 out of GET /api/comp/:id. Locally we keep the default
// (parallel) for speed; CI prioritises determinism.
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [["html", { open: "never" }], ["list"]]
: [["list"]],
Expand All @@ -13,6 +18,12 @@ export default defineConfig({
launchOptions: {
args: ["--no-sandbox", "--disable-setuid-sandbox"],
},
// Keep a full per-step + network trace for any failed test. Lets us see
// whether GHA flakes are races in our code or just slow infrastructure.
// Traces land in test-results/ — branch-deploy.yml and deploy.yml upload
// that path alongside playwright-report/ so they're downloadable from
// the Actions UI. Open one locally with `bunx playwright show-trace`.
trace: "retain-on-failure",
},
projects: [
{
Expand Down
51 changes: 51 additions & 0 deletions web/db/migrations/0008_user_files.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- User-owned files (tracks, tasks, annotations).
--
-- Replaces the browser-IndexedDB storage in web/frontend/src/analysis/storage.ts.
-- Tracks live in R2 under u/{userId}/track/{sha256}.igc.gz; their metadata is
-- here. Tasks are small JSON blobs and live entirely in D1. Annotations are
-- per-(user, track) and cascade with the track.
--
-- Everything cascades on user deletion via the FK to "user". The auth-api
-- delete-account handler additionally lists+deletes R2 objects under
-- u/{userId}/ before the user row goes away.

CREATE TABLE "user_track" (
"user_id" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"track_id" TEXT NOT NULL, -- sha256 hex of content
"r2_key" TEXT NOT NULL, -- u/{userId}/track/{hash}.igc.gz
"filename" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"pilot" TEXT,
"glider" TEXT,
"flight_date" TEXT, -- YYYY-MM-DD
"file_size" INTEGER NOT NULL, -- gzipped bytes (matches what R2 bills)
"stored_at" TEXT NOT NULL,
"last_accessed_at" TEXT NOT NULL,
PRIMARY KEY ("user_id", "track_id")
);
CREATE INDEX "idx_user_track_accessed" ON user_track(user_id, last_accessed_at DESC);

CREATE TABLE "user_task" (
"user_id" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"task_code" TEXT NOT NULL, -- lowercased XContest code or filename slug
"display_name" TEXT NOT NULL,
"xctsk_json" TEXT NOT NULL, -- raw XCTSK JSON (same shape as task.xctsk)
"stored_at" TEXT NOT NULL,
"last_accessed_at" TEXT NOT NULL,
PRIMARY KEY ("user_id", "task_code")
);
CREATE INDEX "idx_user_task_accessed" ON user_task(user_id, last_accessed_at DESC);

CREATE TABLE "user_annotation" (
"user_id" TEXT NOT NULL,
"track_id" TEXT NOT NULL,
"stroke_id" TEXT NOT NULL, -- UUID generated client-side
"color" TEXT NOT NULL,
"width" REAL NOT NULL,
"points" TEXT NOT NULL, -- JSON [lng,lat][]
"timestamp" INTEGER NOT NULL,
PRIMARY KEY ("user_id", "track_id", "stroke_id"),
FOREIGN KEY ("user_id", "track_id")
REFERENCES "user_track"("user_id", "track_id") ON DELETE CASCADE
);
CREATE INDEX "idx_user_annotation_track" ON user_annotation(user_id, track_id);
Loading
Loading