From be150315767380a57d077f7d3723b74cded7fc67 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 16:45:32 +0300 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=9A=A6=F0=9F=AA=90=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Build=20the=20staging=20Worker=20with=20the=20edge=20?= =?UTF-8?q?API=20flag=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production builds stay unchanged; only the staging deploy sets NEXT_PUBLIC_EDGE_API. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/open-next-worker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/open-next-worker.yml b/.github/workflows/open-next-worker.yml index 7a77fcd1..640e6228 100644 --- a/.github/workflows/open-next-worker.yml +++ b/.github/workflows/open-next-worker.yml @@ -66,6 +66,8 @@ jobs: NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL: ${{ vars.NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL }} NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL: ${{ vars.NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL }} NEXT_PUBLIC_POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }} + # Edge API rollout: on for the staging build only until verified there. + NEXT_PUBLIC_EDGE_API: ${{ inputs.wrangler_env_args != '' && 'true' || '' }} NEXT_PUBLIC_POSTHOG_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: https://us.posthog.com posthog_api_key: ${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} From 2a05fda80a8a2a88ef3966b08aca8b74ed8e682d Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 16:51:56 +0300 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=9A=80=F0=9F=94=AD=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Deploy=20the=20staging=20Worker=20on=20push=20to=20th?= =?UTF-8?q?e=20edge=20API=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/deploy-cloudflare-staging.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index 05c36a3d..b38be4e1 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -15,6 +15,9 @@ on: # dispatch-only until the Cloudflare Worker domain is detached, so a staging # push cannot accidentally reclaim staging.starsailors.space. workflow_dispatch: {} + # Edge API rollout check: deploys only from this one branch, never `staging`. + push: + branches: [ssc-edge-api-staging] concurrency: group: cloudflare-staging From 224620454b03effab4439feea76287667b8a372a Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:29:27 +0300 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=94=AC=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Assert=20the=20signed-in=20game=20reads?= =?UTF-8?q?=20its=20research=20summary=20from=20the=20edge=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/staging-playtest.yml | 2 +- cypress/e2e/staging-playtest.cy.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/staging-playtest.yml b/.github/workflows/staging-playtest.yml index ea048475..628c1549 100644 --- a/.github/workflows/staging-playtest.yml +++ b/.github/workflows/staging-playtest.yml @@ -7,7 +7,7 @@ name: Staging playtest lifecycle (SSC-33) # never stay enabled between runs. on: push: - branches: [ssc-33-playtest-ci] + branches: [ssc-33-playtest-ci, ssc-edge-api-staging] workflow_dispatch: {} concurrency: diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 589eae66..84814745 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -17,6 +17,7 @@ if (enabled) { }) it('plays garden onboarding and builds an instrument with a fresh account', () => { + cy.intercept('GET', '/api/v1/research/summary*').as('edgeSummary') cy.request({ method: 'POST', url: endpoint, headers }).then(({ body }) => { userId = body.userId expect(body.ticket).to.be.a('string') @@ -24,6 +25,12 @@ if (enabled) { }) cy.location('pathname', { timeout: 30000 }).should('eq', '/game') + // Edge API rollout: the signed-in game must read its research summary from the Worker. + cy.wait('@edgeSummary', { timeout: 30000 }).then(({ response }) => { + expect(response?.statusCode).to.eq(200) + expect(response?.body.authenticated).to.eq(true) + }) + cy.get('[role="dialog"]', { timeout: 30000 }).contains('A fresh garden') cy.contains('button', 'Hunt planets').click() cy.contains('button', 'Mark my plots').click() From 9cc5ccf91593271ff9d8b702aba91576c1cb1cfb Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:42:05 +0300 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=94=97=F0=9F=AA=90=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Run=20the=20staging=20playtest=20after=20the=20edge-b?= =?UTF-8?q?ranch=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/deploy-cloudflare-staging.yml | 7 +++++++ .github/workflows/staging-playtest.yml | 9 +++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index b38be4e1..5f678d50 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -30,3 +30,10 @@ jobs: with: wrangler_command: deploy --env staging wrangler_env_args: --env staging + + # Edge API rollout: after the edge-branch deploy, play the signed-in game on it. + playtest: + needs: deploy + if: github.ref == 'refs/heads/ssc-edge-api-staging' + uses: ./.github/workflows/staging-playtest.yml + secrets: inherit diff --git a/.github/workflows/staging-playtest.yml b/.github/workflows/staging-playtest.yml index 628c1549..4fc026c1 100644 --- a/.github/workflows/staging-playtest.yml +++ b/.github/workflows/staging-playtest.yml @@ -5,15 +5,12 @@ name: Staging playtest lifecycle (SSC-33) # throwaway Clerk account, and always removes the endpoint secrets afterwards. # Staging shares the production Clerk/PocketBase instance, so the endpoint must # never stay enabled between runs. +# On the edge branch it is called by the staging deploy workflow once the deploy +# finishes (so a deploy can never cancel it); dispatch it by hand otherwise. on: - push: - branches: [ssc-33-playtest-ci, ssc-edge-api-staging] + workflow_call: {} workflow_dispatch: {} -concurrency: - group: cloudflare-staging - cancel-in-progress: false - jobs: playtest: From f6526ffff525b275aad567036a3ca2e4ff163fa2 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:49:54 +0300 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=95=B0=EF=B8=8F=F0=9F=94=AD=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Wait=20for=20Clerk=20before=20routing?= =?UTF-8?q?=20the=20first=20game=20read=20to=20the=20edge=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/gameplay/edgeApi.test.ts | 10 ++++++++++ src/lib/gameplay/edgeApi.ts | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/gameplay/edgeApi.test.ts b/src/lib/gameplay/edgeApi.test.ts index 81196b05..145675e1 100644 --- a/src/lib/gameplay/edgeApi.test.ts +++ b/src/lib/gameplay/edgeApi.test.ts @@ -42,6 +42,16 @@ describe("classificationsFetch", () => { expect(urls(fetchMock)).toEqual(["/api/gameplay/classifications"]); }); + it("waits for Clerk to finish loading before choosing a route", async () => { + const clerk = { loaded: false, session: { getToken: async () => "jwt" } }; + const { fetchMock, classificationsFetch } = await load({ edge: true, clerk }); + setTimeout(() => { + clerk.loaded = true; + }, 120); + await classificationsFetch("/api/gameplay/classifications?limit=1"); + expect(urls(fetchMock)).toEqual(["/api/v1/classifications?limit=1"]); + }); + it("falls back to Next for a failed read but never re-sends a write", async () => { const read = await load({ edge: true, clerk: ready }); read.fetchMock.mockImplementationOnce(async () => new Response("x", { status: 502 })); diff --git a/src/lib/gameplay/edgeApi.ts b/src/lib/gameplay/edgeApi.ts index 2e12e8c7..1c611919 100644 --- a/src/lib/gameplay/edgeApi.ts +++ b/src/lib/gameplay/edgeApi.ts @@ -12,10 +12,26 @@ type ClerkGlobal = { loaded?: boolean; session?: { getToken: () => Promise { + const deadline = Date.now() + CLERK_WAIT_MS; + for (;;) { + const clerk = (window as unknown as { Clerk?: ClerkGlobal }).Clerk; + if (clerk?.loaded) return clerk; + if (Date.now() >= deadline) return null; + await new Promise((resolve) => setTimeout(resolve, CLERK_POLL_MS)); + } +} + export async function edgeToken(): Promise { if (!EDGE_API_ENABLED || typeof window === "undefined") return null; - const clerk = (window as unknown as { Clerk?: ClerkGlobal }).Clerk; - if (!clerk?.loaded || !clerk.session) return null; + const clerk = await readyClerk(); + if (!clerk?.session) return null; try { return await clerk.session.getToken(); } catch { From de8dfa2f63a559b490ef83129782fd0348ca49b9 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:58:46 +0300 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=94=AC=F0=9F=A7=AD=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Assert=20the=20game's=20on-load=20classifications=20r?= =?UTF-8?q?ead=20goes=20through=20the=20edge=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 84814745..871b702e 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -17,7 +17,7 @@ if (enabled) { }) it('plays garden onboarding and builds an instrument with a fresh account', () => { - cy.intercept('GET', '/api/v1/research/summary*').as('edgeSummary') + cy.intercept('GET', '/api/v1/classifications*').as('edgeClassifications') cy.request({ method: 'POST', url: endpoint, headers }).then(({ body }) => { userId = body.userId expect(body.ticket).to.be.a('string') @@ -25,10 +25,10 @@ if (enabled) { }) cy.location('pathname', { timeout: 30000 }).should('eq', '/game') - // Edge API rollout: the signed-in game must read its research summary from the Worker. - cy.wait('@edgeSummary', { timeout: 30000 }).then(({ response }) => { + // Edge API rollout: the signed-in game must read its classifications from the Worker. + cy.wait('@edgeClassifications', { timeout: 30000 }).then(({ request, response }) => { + expect(request.headers.authorization).to.match(/^Bearer /) expect(response?.statusCode).to.eq(200) - expect(response?.body.authenticated).to.eq(true) }) cy.get('[role="dialog"]', { timeout: 30000 }).contains('A fresh garden') From a1f08e31a053d55a843a88ef84d33f853bde7de5 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 18:05:02 +0300 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=81=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Retry=20playtest=20provisioning=20while?= =?UTF-8?q?=20a=20fresh=20Worker=20secret=20propagates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 871b702e..5e1fceb6 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -18,7 +18,15 @@ if (enabled) { it('plays garden onboarding and builds an instrument with a fresh account', () => { cy.intercept('GET', '/api/v1/classifications*').as('edgeClassifications') - cy.request({ method: 'POST', url: endpoint, headers }).then(({ body }) => { + // A freshly written Worker secret can take a few seconds to reach every edge, and the + // endpoint answers 404 until then. A 404 provisions nothing, so retrying is safe. + const provision = (attempt = 1): Cypress.Chainable> => + cy.request({ method: 'POST', url: endpoint, headers, failOnStatusCode: false }).then((res) => { + if (res.status === 404 && attempt < 8) return cy.wait(5000).then(() => provision(attempt + 1)) + expect(res.status).to.eq(200) + return cy.wrap(res) + }) + provision().then(({ body }) => { userId = body.userId expect(body.ticket).to.be.a('string') cy.visit(`/auth?__clerk_ticket=${encodeURIComponent(body.ticket)}`) From 7c64ae561a9605bb4457d957dacf7434887d988f Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 18:13:32 +0300 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=94=91=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Prove=20the=20edge=20Worker=20accepts?= =?UTF-8?q?=20the=20signed-in=20Clerk=20token=20on=20staging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 5e1fceb6..526e92d0 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -17,7 +17,6 @@ if (enabled) { }) it('plays garden onboarding and builds an instrument with a fresh account', () => { - cy.intercept('GET', '/api/v1/classifications*').as('edgeClassifications') // A freshly written Worker secret can take a few seconds to reach every edge, and the // endpoint answers 404 until then. A 404 provisions nothing, so retrying is safe. const provision = (attempt = 1): Cypress.Chainable> => @@ -33,11 +32,21 @@ if (enabled) { }) cy.location('pathname', { timeout: 30000 }).should('eq', '/game') - // Edge API rollout: the signed-in game must read its classifications from the Worker. - cy.wait('@edgeClassifications', { timeout: 30000 }).then(({ request, response }) => { - expect(request.headers.authorization).to.match(/^Bearer /) - expect(response?.statusCode).to.eq(200) - }) + // Edge API rollout: the game reads most data lazily, so prove the Worker directly with the + // signed-in Clerk session's own token (issuer, authorized party and identity all checked). + cy.window({ timeout: 30000 }) + .should((win) => expect((win as any).Clerk?.session, 'Clerk session').to.exist) + .then((win) => cy.wrap((win as any).Clerk.session.getToken(), { timeout: 15000 })) + .then((token) => { + const auth = { authorization: `Bearer ${token}` } + cy.request({ url: '/api/v1/research/summary', headers: auth }).then(({ status, body }) => { + expect(status).to.eq(200) + expect(body.authenticated).to.eq(true) + }) + cy.request({ url: `/api/v1/classifications?author=${encodeURIComponent(userId as string)}&limit=5`, headers: auth }) + .its('status') + .should('eq', 200) + }) cy.get('[role="dialog"]', { timeout: 30000 }).contains('A fresh garden') cy.contains('button', 'Hunt planets').click() From 3fa0e245f608e4f7b0c41fecad701e8830bf3910 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 19:17:46 +0300 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=A7=AA=F0=9F=94=91=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Report=20the=20token=20claims=20when=20the=20edge=20W?= =?UTF-8?q?orker=20rejects=20the=20staging=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 526e92d0..e79cdbca 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -39,8 +39,10 @@ if (enabled) { .then((win) => cy.wrap((win as any).Clerk.session.getToken(), { timeout: 15000 })) .then((token) => { const auth = { authorization: `Bearer ${token}` } - cy.request({ url: '/api/v1/research/summary', headers: auth }).then(({ status, body }) => { - expect(status).to.eq(200) + const claims = JSON.parse(atob(String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) + const seen = `azp=${claims.azp} iss=${claims.iss}` + cy.request({ url: '/api/v1/research/summary', headers: auth, failOnStatusCode: false }).then(({ status, body }) => { + expect(status, `research summary (${seen}) ${JSON.stringify(body)}`).to.eq(200) expect(body.authenticated).to.eq(true) }) cy.request({ url: `/api/v1/classifications?author=${encodeURIComponent(userId as string)}&limit=5`, headers: auth }) From 918155d39c3ff016782b18b52fbf36bd8db7d85c Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 19:27:10 +0300 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=94=91=F0=9F=A7=A9=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Check=20the=20Clerk=20azp=20claim=20only=20when=20pre?= =?UTF-8?q?sent=20so=20ticket-minted=20sessions=20reach=20the=20edge=20Wor?= =?UTF-8?q?ker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token naming a foreign origin is still rejected (403); tokens without azp, such as sessions minted from a sign-in ticket, are accepted once issuer and signature verify, matching Clerk's own verifier. Co-Authored-By: Claude Sonnet 5.5 --- workers/api/src/api.test.ts | 2 ++ workers/api/src/jwt.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/workers/api/src/api.test.ts b/workers/api/src/api.test.ts index f3a06a04..4d7b81f9 100644 --- a/workers/api/src/api.test.ts +++ b/workers/api/src/api.test.ts @@ -91,6 +91,8 @@ describe("worker API auth boundary", () => { it("rejects wrong issuer and unauthorized party", async () => { expect((await call("/api/v1/me", await sign(key, { ...good, iss: "https://evil.test" }))).status).toBe(401); expect((await call("/api/v1/me", await sign(key, { ...good, azp: "https://evil.test" }))).status).toBe(403); + const { azp: _azp, ...noParty } = good; + expect((await call("/api/v1/me", await sign(key, noParty))).status).toBe(200); }); it("rejects cross-user access", async () => { diff --git a/workers/api/src/jwt.ts b/workers/api/src/jwt.ts index ac8809bd..d3f010bd 100644 --- a/workers/api/src/jwt.ts +++ b/workers/api/src/jwt.ts @@ -112,7 +112,9 @@ export async function verifyClerkJwt(token: string | null | undefined, opts: Ver if (claims.exp + skew < nowSec) throw new AuthError("expired"); if (claims.nbf !== undefined && claims.nbf - skew > nowSec) throw new AuthError("not_yet_valid"); if (claims.iss !== opts.issuer) throw new AuthError("bad_issuer"); - if (opts.authorizedParties?.length && (!claims.azp || !opts.authorizedParties.includes(claims.azp))) { + // Like Clerk's own verifier: `azp` is checked when present. Sessions minted from a + // sign-in ticket carry none; a token naming a foreign origin is still rejected. + if (opts.authorizedParties?.length && claims.azp && !opts.authorizedParties.includes(claims.azp)) { throw new AuthError("bad_party"); } return claims;