diff --git a/admin/public/confirmed.js b/admin/public/confirmed.js index b784aef9..c2cccb96 100644 --- a/admin/public/confirmed.js +++ b/admin/public/confirmed.js @@ -1,3 +1,22 @@ -// Supabase may return a short-lived session in the URL fragment after -// confirmation. This informational page never needs it. -if (location.hash || location.search) history.replaceState(null, '', location.pathname); +// Supabase verifies the link before redirecting here. The page must not claim +// success on a direct visit, and it never needs to retain a returned session. +const hash = new URLSearchParams(location.hash.slice(1)); +const query = new URLSearchParams(location.search); +const error = hash.get('error_description') || query.get('error_description') || + hash.get('error') || query.get('error'); +if (hash.get('type') === 'recovery' && hash.get('access_token') && !error) { + const reset = new URL('/reset-password', location.origin); + reset.hash = location.hash; + location.replace(reset.toString()); +} else { + history.replaceState(null, '', location.pathname); + const heading = document.getElementById('heading'); + const message = document.getElementById('message'); + if (error) { + heading.textContent = 'Confirmation did not finish'; + message.textContent = error; + } else if (hash.get('access_token')) { + heading.textContent = 'Email confirmed ✓'; + message.textContent = "You're all set. Open the One Concept app and sign in to get today's concept."; + } +} diff --git a/admin/public/confirmed/index.html b/admin/public/confirmed/index.html index 55b83f85..f2760ab5 100644 --- a/admin/public/confirmed/index.html +++ b/admin/public/confirmed/index.html @@ -3,14 +3,14 @@ - One Concept — email confirmed + One Concept — email confirmation
-

Email confirmed

-

Open the One Concept app on your phone and sign in to get today's concept.

+

Check your email link

+

Open the confirmation link from your email to finish setting up your account.

diff --git a/admin/tests/auth-landing.spec.ts b/admin/tests/auth-landing.spec.ts new file mode 100644 index 00000000..da7a144d --- /dev/null +++ b/admin/tests/auth-landing.spec.ts @@ -0,0 +1,103 @@ +import { expect, test, type Page } from "@playwright/test"; +import { publicPage } from "../../supabase/functions/api/public_pages.ts"; + +const origin = "https://one-concept.supabase.co"; +const base = `${origin}/functions/v1/api`; +const config = { supabaseUrl: origin, anonKey: "public-anon-key" }; + +async function serveAuthPages(page: Page) { + await page.route(`${base}/**`, async route => { + const url = new URL(route.request().url()); + const response = publicPage(url.pathname.slice("/functions/v1/api".length), "GET", config, new Headers()); + if (!response) return route.fulfill({ status: 404, body: "Not found" }); + return route.fulfill({ + status: response.status, + headers: Object.fromEntries(response.headers), + body: await response.text(), + }); + }); +} + +test("direct confirmation visit does not claim that an email was verified", async ({ page }) => { + await serveAuthPages(page); + await page.goto(`${base}/confirmed`); + await expect(page.getByRole("heading", { name: "Check your email link" })).toBeVisible(); + await expect(page.getByText("Email confirmed ✓")).toHaveCount(0); + await expect(page).toHaveTitle("One Concept — email confirmation"); +}); + +test("verified confirmation link reports success and removes its session fragment", async ({ page }) => { + await serveAuthPages(page); + await page.goto(`${base}/confirmed#access_token=confirmed-token&type=signup`); + await expect(page.getByRole("heading", { name: "Email confirmed ✓" })).toBeVisible(); + await expect(page).toHaveURL(`${base}/confirmed`); +}); + +test("expired confirmation link reports the error without claiming success", async ({ page }) => { + await serveAuthPages(page); + await page.goto(`${base}/confirmed#error=access_denied&error_description=Email%20link%20expired`); + await expect(page.getByRole("heading", { name: "Confirmation did not finish" })).toBeVisible(); + await expect(page.getByText("Email link expired")).toBeVisible(); + await expect(page).toHaveURL(`${base}/confirmed`); +}); + +test("recovery link reaches the reset form and updates the password with its token", async ({ page }) => { + await serveAuthPages(page); + let authRequest: { method: string; authorization: string | undefined; apikey: string | undefined; password: string } | undefined; + await page.route(`${origin}/auth/v1/user`, async route => { + authRequest = { + method: route.request().method(), + authorization: route.request().headers().authorization, + apikey: route.request().headers().apikey, + password: route.request().postDataJSON().password, + }; + await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }); + + await page.goto(`${base}/confirmed#access_token=recovery-token&type=recovery`); + await expect(page).toHaveURL(`${base}/reset-password`); + await page.getByLabel("New password", { exact: true }).fill("safe-new-password"); + await page.getByLabel("Confirm new password").fill("safe-new-password"); + await page.getByRole("button", { name: "Update password" }).click(); + await expect(page.getByRole("alert")).toContainText("Password updated"); + expect(authRequest).toEqual({ + method: "PUT", + authorization: "Bearer recovery-token", + apikey: "public-anon-key", + password: "safe-new-password", + }); +}); + +test("expired recovery link shows an error and never calls Auth", async ({ page }) => { + await serveAuthPages(page); + let called = false; + await page.route(`${origin}/auth/v1/user`, async route => { + called = true; + await route.fulfill({ status: 500, body: "Unexpected request" }); + }); + await page.goto(`${base}/reset-password#error=access_denied&error_description=Email%20link%20expired`); + await expect(page.getByRole("alert")).toHaveText("Email link expired"); + await expect(page.getByRole("button", { name: "Update password" })).toBeHidden(); + await expect(page).toHaveURL(`${base}/reset-password`); + expect(called).toBe(false); +}); + +test("password update rejects Auth redirects without forwarding the recovery token", async ({ page }) => { + await serveAuthPages(page); + let redirected = false; + await page.route(`${origin}/auth/v1/user`, route => route.fulfill({ + status: 302, + headers: { location: `${origin}/auth/v1/redirect-target` }, + })); + await page.route(`${origin}/auth/v1/redirect-target`, async route => { + redirected = true; + await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }); + + await page.goto(`${base}/reset-password#access_token=recovery-token&type=recovery`); + await page.getByLabel("New password", { exact: true }).fill("safe-new-password"); + await page.getByLabel("Confirm new password").fill("safe-new-password"); + await page.getByRole("button", { name: "Update password" }).click(); + await expect(page.getByRole("alert")).toContainText("Could not reset your password"); + expect(redirected).toBe(false); +}); diff --git a/admin/tests/public-pages.spec.ts b/admin/tests/public-pages.spec.ts index 5269311d..9a46faba 100644 --- a/admin/tests/public-pages.spec.ts +++ b/admin/tests/public-pages.spec.ts @@ -16,6 +16,44 @@ test.beforeEach(async ({ page }) => { })); }); +test("review privacy policy opens the published learner privacy page", async ({ page }) => { + const html = await readFile(resolve("public/privacy/index.html"), "utf8"); + await page.route("**/privacy", route => route.fulfill({ contentType: "text/html", body: html })); + await page.goto("/privacy.html"); + await page.getByRole("link", { name: "One Concept learning app privacy policy" }).click(); + await expect(page).toHaveURL(/\/privacy\/?$/); + await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible(); +}); + +test("Netlify confirmation stays neutral until an Auth redirect and clears its session", async ({ page }) => { + const html = await readFile(resolve("public/confirmed/index.html"), "utf8"); + await page.route("**/confirmed", route => route.fulfill({ contentType: "text/html", body: html })); + await page.goto("/confirmed"); + await expect(page.getByRole("heading", { name: "Check your email link" })).toBeVisible(); + await expect(page).toHaveTitle("One Concept — email confirmation"); + + await page.goto("/"); + await page.goto("/confirmed#access_token=confirmed-token&type=signup"); + await expect(page.getByRole("heading", { name: "Email confirmed ✓" })).toBeVisible(); + await expect(page).toHaveURL(/\/confirmed$/); + + await page.goto("/"); + await page.goto("/confirmed#error=access_denied&error_description=Email%20link%20expired"); + await expect(page.getByRole("heading", { name: "Confirmation did not finish" })).toBeVisible(); + await expect(page.locator("#message")).toHaveText("Email link expired"); + await expect(page).toHaveURL(/\/confirmed$/); +}); + +test("Netlify confirmation forwards a recovery session to its reset form", async ({ page }) => { + const html = await readFile(resolve("public/confirmed/index.html"), "utf8"); + const reset = await readFile(resolve("public/reset-password/index.html"), "utf8"); + await page.route("**/confirmed", route => route.fulfill({ contentType: "text/html", body: html })); + await page.route("**/reset-password", route => route.fulfill({ contentType: "text/html", body: reset })); + await page.goto("/confirmed#access_token=recovery-token&type=recovery"); + await expect(page).toHaveURL(/\/reset-password$/); + await expect(page.locator("#reset-form")).toBeVisible(); +}); + test("shared profile reads only public JSON and hides a revoked profile", async ({ page }) => { const html = await readFile(resolve("public/p/index.html"), "utf8"); await page.route(`**/p/${token}`, (route) => route.fulfill({ contentType: "text/html", body: html })); diff --git a/docs/AUTH_REDIRECTS.md b/docs/AUTH_REDIRECTS.md index cb39b996..74e59115 100644 --- a/docs/AUTH_REDIRECTS.md +++ b/docs/AUTH_REDIRECTS.md @@ -1,94 +1,58 @@ -# Authentication email redirects - -Supabase verifies email addresses itself. After verification, it redirects the -browser to a permitted HTTPS URL. One Concept uses its FastAPI public pages for -that final browser step: - -- `GET /confirmed` displays the One Concept confirmation page and asks the - person to return to the app. -- `GET /reset-password` displays the One Concept password-reset form. - -These pages are product pages served by the API. Railway is only the current -hosting provider; its name, error page, or dashboard must never be part of the -learner experience. - -The mobile app supplies `emailRedirectTo` for new sign-ups and `redirectTo` for -password recovery from `EXPO_PUBLIC_API_BASE_URL`. This keeps both flows on the -same deployed API origin instead of relying only on a stale Supabase Site URL. -Supabase must allow those destinations before it will honor them. - -## Owner checklist - -Do these **before merging the PR**. They are safe while the PR is open and do -not require a mobile build, database migration, secret, or payment. - -1. In Railway, open the production **api** service, then **Settings → - Networking**. Copy the active public HTTPS domain exactly. Do not copy a - deployment ID, internal service URL, or an old removed domain. -2. In a private browser window, open `https://YOUR-API-DOMAIN/confirmed`. - Continue only when it displays the One Concept **“Email confirmed”** page. - If Railway shows “The train has not arrived at the station”, wait for the - deployment/domain to become active or create and attach a new Railway domain - to the current api service. Do not place that broken address in Supabase. -3. Open Supabase **Authentication → URL Configuration** for the production - project. Set **Site URL** to `https://YOUR-API-DOMAIN`. This remains the - fallback for old app versions and any request that lacks a redirect value. -4. Under **Redirect URLs**, add these exact entries: - - ```text - https://YOUR-API-DOMAIN/confirmed - https://YOUR-API-DOMAIN/reset-password - ``` - - Keep any still-working previous recovery URL until links already sent to - users have expired. Do not add wildcards, credentials, a Railway dashboard - URL, `localhost`, or a URL with a query string. -5. In Expo/EAS, inspect the **production** value of - `EXPO_PUBLIC_API_BASE_URL`. It must equal `https://YOUR-API-DOMAIN` with no - trailing path, query, fragment, username, or password. The value is public - configuration; never put Supabase service keys or database passwords there. -6. Save the dashboard settings and capture the exact domain in the release - record. No source-code configuration file should contain the live domain if - EAS already owns that public setting. - -## After this PR merges to `develop` - -1. Wait for the normal preview EAS update workflow to complete, then install or - open the staging/preview app. A merge to `develop` does **not** update the - production app. -2. Create a fresh test account with an inbox you control. Open the confirmation - email and check this sequence: +# Authentication email redirects after the Supabase cutover + +Supabase Auth verifies email links before redirecting the browser to an allowed +HTTPS landing URL. The production mobile release on `main` sends new sign-up and +recovery links to the Netlify browser pages built from +`EXPO_PUBLIC_WEB_BASE_URL`. Those pages remain the current explicit redirect +destinations; their `/confirmed` and `/reset-password` flows are tested in +`admin/tests/public-pages.spec.ts`. + +The Supabase `api` Edge function also serves browser pages at: + +- `/functions/v1/api/confirmed`: shows a neutral message on direct visits and a + confirmation message after an Auth redirect. A recovery session sent to this + page is forwarded to the reset page with its URL fragment. +- `/functions/v1/api/reset-password`: accepts a recovery session in the URL + fragment, removes it from the address bar, and updates the password through + Supabase Auth. A missing or expired link displays an error. +- `/functions/v1/api/privacy`: shows the learner privacy policy. + +Once configured as the Site URL, the Edge pages provide a fallback for new +links that omit an explicit redirect. They also support the API-based redirect +configuration planned on `develop`. They contain only the +Supabase project URL and publishable key; no service credential is sent to the +browser. Their route and browser behavior are covered in +`supabase/functions/api/handler.test.mjs` and +`admin/tests/auth-landing.spec.ts`. + +## Production deployment and verification + +1. Deploy the reviewed `api` Edge function revision through the protected + production deployment workflow. Confirm `/health` and each public page over + HTTPS. A direct visit to `/confirmed` must ask the visitor to open an email + link; a direct visit to `/reset-password` must report an invalid link. +2. In the matching Supabase project, allow the exact `/confirmed` and + `/reset-password` Edge URLs under **Authentication → URL Configuration**. + Keep the working Netlify redirect URLs while the released mobile app still + uses them. Do not add a wildcard or a retired host. ```text - Supabase verifies the address → One Concept confirmation page → sign in succeeds + https://egdszjoesxlccauahmsx.supabase.co/functions/v1/api/confirmed + https://egdszjoesxlccauahmsx.supabase.co/functions/v1/api/reset-password ``` - The browser must not show a Railway 404 page. Test password recovery too; - it must open the One Concept reset-password page and permit sign-in with the - new password. -3. If the test fails, keep the PR merged but stop the production release. Check - the exact generated email URL, the Railway domain and the Supabase allowlist - rather than changing templates or exposing a credential. - -## Before production users receive the fix - -This is a JavaScript mobile change. After the preview test passes, include it in -a normal `develop` → `main` release PR. After that release PR is merged, verify -the deployed API health and manually run the repository’s **Release** workflow -on the verified `main` commit. That publishes the production EAS OTA update to -compatible installed builds. A production user receives it on a later app -launch; an update is not instantaneous. - -Changing the Supabase Site URL in the checklist is still worthwhile before the -OTA: it repairs the fallback redirect used by older app versions. Do not claim -production confirmation is fixed until a new controlled account has completed -the real email flow. - -## Future own-domain upgrade - -When One Concept has a domain, point a subdomain such as -`app.oneconcept.example` at the API service, wait for HTTPS provisioning, then -repeat the checklist using that domain. A true browser-to-app open button later -requires Android App Links (and iOS Universal Links) hosted on that domain. It -is separate from email verification and should be introduced with its own -native release, association-file validation, and device testing. +3. Keep the **Site URL** fallback on a working page. Change it to the Edge + `/confirmed` page only after that page is deployed and its recovery + forwarding is verified. The Site URL is used when a client supplies no + explicit redirect. +4. Check Expo's production `EXPO_PUBLIC_API_BASE_URL`, + `EXPO_PUBLIC_SUPABASE_URL`, and `EXPO_PUBLIC_WEB_BASE_URL` against the + production Supabase project and current Netlify site. The publishable key + must belong to the same project. +5. With an inbox and account you control, request a fresh signup confirmation + and password recovery email. Check that confirmation permits sign-in, and + that recovery accepts a new password and permits sign-in with it. Do not + share email links, URL fragments, tokens, or passwords in logs or chat. + +A `200` response from a landing page proves only that the page is served; it +does not prove that Supabase verified the email or changed the password. diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index feb4ad45..9fc77d2b 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -12,7 +12,7 @@ learned history, streaks, likes, saved concepts, and push reminders. | Area | Entry points and purpose | | --- | --- | | Mobile | `mobile/README.md` is the local developer on-ramp; `mobile/index.ts` registers `mobile/App.tsx`; Expo SDK 57, React Native 0.86, React 19, TypeScript. `mobile/src/screens/AboutScreen.tsx` links learners to the public review explanation. | -| Review website and public pages | `admin/README.md`, `admin/src/main.tsx`, `App.tsx`; static editorial workspace using Supabase Auth and the configured API. `admin/public/confirmed/`, `reset-password/`, `privacy/`, and `p/` host browser-only pages outside Supabase Edge Functions. `admin/vite.config.ts` emits publishable-only page config and `admin/src/hosting.ts` emits security headers. `admin/public/_redirects` maps public paths and SPA links on Netlify. | +| Review website and public pages | `admin/README.md`, `admin/src/main.tsx`, `App.tsx`; static editorial workspace using Supabase Auth and the configured API. `admin/public/confirmed/`, `reset-password/`, `privacy/`, and `p/` host browser-only pages on Netlify. `supabase/functions/api/public_pages.ts` provides equivalent Edge confirmation, recovery, and learner privacy pages. `admin/vite.config.ts` emits publishable-only page config and `admin/src/hosting.ts` emits security headers. `admin/public/_redirects` maps public paths and SPA links on Netlify. | | Backend | `backend/app/main.py`; FastAPI, async SQLAlchemy/asyncpg, Pydantic settings, ES256 JWT verification. Docker uses Python 3.12. | | Database | `backend/migrations/`; Supabase PostgreSQL schema, RLS, seeds, and incremental migrations. | | Content lifecycle | `docs/CONTENT_ARCHITECTURE.md`, `docs/CONTENT_OPERATIONS.md`; portable subject/curriculum imports, durable refill, reviewed publication, daily review, protected health report. | diff --git a/docs/EMAIL_TEMPLATES.md b/docs/EMAIL_TEMPLATES.md index 74fd8087..18efcb3d 100644 --- a/docs/EMAIL_TEMPLATES.md +++ b/docs/EMAIL_TEMPLATES.md @@ -29,9 +29,11 @@ must both pass through that verification. Password changed reports a completed change and intentionally has no token or reset-link placeholder. It directs unrecognized changes to Forgot password in the app and the existing support email. -Signup and recovery both request a landing page on the mobile build's configured -API origin. The Site URL remains the fallback for earlier app versions, and the -corresponding HTTPS destinations must be allowed in Supabase's URL Configuration. +The current production mobile app requests signup and recovery landing pages on +the Netlify site configured by `EXPO_PUBLIC_WEB_BASE_URL`. The `develop` branch +prepares API-based landing pages for a later mobile release. Supabase's Site URL +remains the fallback when a client does not supply an explicit redirect. Keep +the HTTPS destinations used by released clients in Supabase's URL Configuration. Do not replace verification links with those landing-page URLs. Follow the [authentication redirect runbook](AUTH_REDIRECTS.md) before testing the emails. See [Supabase email templates](https://supabase.com/docs/guides/auth/auth-email-templates). diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 954cbe32..aa1cd2cb 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -7,6 +7,19 @@ claims as completed work. ## Current status +### Production auth landing hotfix review — PR #460, 2026-10-28 + +- Scope: review the `main` hotfix for Supabase Edge confirmation, recovery, and privacy pages; keep the existing production Netlify redirects working. PR [#460](https://github.com/Coding-Moves/one-concept/pull/460) remains open for independent review and the protected production deployment. +- Follow-up review scope: recheck the public browser output, hosted gateway configuration, and redirect documentation against the current `main` mobile app. The Edge API has `verify_jwt = false` in `supabase/config.toml`, so anonymous email-link navigation reaches the handler; private routes still verify account tokens. The direct confirmation page tab title and the email-template guide needed correction, and browser coverage lacked confirmation success/expiry cases. +- `91ce356` gives direct confirmation visits a neutral tab title, marks public Edge pages noindex, and adds Chromium success/expiry tests. The focused Chromium tests (3), 97 Edge handler tests, Edge TypeScript check, and admin TypeScript check passed locally. The email-template runbook now describes the Netlify origin used by the current production app. +- Hosted [PR quality run 40701939325](https://github.com/Coding-Moves/one-concept/actions/runs/40701939325) passed mobile, backend, and review-site browser/build jobs on `8c90c71`; migration and Netlify checks passed or were intentionally skipped. A real inbox round trip still waits for the protected production Edge deployment after an approved merge. +- `7ec6753` rejects Auth redirects and ambient browser credentials on the Edge reset page, closing a recovery-token forwarding gap. A Chromium redirect regression, all six focused auth-page browser tests, 97 Edge handler tests, and Edge/admin TypeScript checks passed locally. Hosted [PR quality run 40703478981](https://github.com/Coding-Moves/one-concept/actions/runs/40703478981) passed mobile, backend, and review-site browser/build jobs. +- `b90b5d5` corrects the released Netlify confirmation page, which displayed a success heading on direct visits. It now starts neutral, reports Auth errors, and forwards a fallback recovery session to the existing reset form. Two new Chromium tests cover direct, success, expired, and recovery visits. All 59 review-site Chromium tests and admin TypeScript passed locally. Hosted [PR quality run 40704388844](https://github.com/Coding-Moves/one-concept/actions/runs/40704388844) passed mobile, backend, and review-site browser/build jobs; migration and Netlify checks passed or were appropriately skipped. A real inbox round trip remains after protected production deployment. +- `9dfd2eb` makes a direct `/confirmed` visit neutral instead of falsely reporting verified email, matching the correction on `develop`. `32fd36f` adds script-level recovery edge cases and Chromium tests for direct confirmation, a successful recovery update, and an expired link. +- `922e77e` replaces the retired-host redirect runbook with the current `main` mobile redirect and Edge fallback sequence, and maps the Edge page source. Local validation: 97 Edge handler tests, Supabase function TypeScript check, 17 admin unit tests, admin build with CI fixture variables, and all 53 Chromium tests passed. Hosted PR checks and a real inbox recovery test remain to be verified after push; no migration is involved. +- Live HTTP checks found the existing Netlify `/privacy` page returns 200 while the new production Edge `/privacy` page still returns 404 before deployment. `1e3eb2d` keeps the reviewer policy link on the working Netlify page and adds a focused Chromium navigation test, which passed. This removes a link outage between a future merge and the protected Edge deployment; #459 is already merged to `develop` for a later release. +- Hosted [PR quality run 40699756621](https://github.com/Coding-Moves/one-concept/actions/runs/40699756621) passed mobile, backend, and the 54-test review-site browser/build job on `fdeea5f`. Migration and Netlify checks passed or were intentionally skipped where no matching change applied. A real email-link round trip remains a post-deploy check because the production Edge pages still return 404 before this PR is merged and the protected deploy runs. + ### Supabase production release/cutover — issue #418, 2026-10-21 - Scope: prepare one reviewed Step 3 branch from merged `develop` at `caaf0be`, retaining production on Railway until browser, deploy, OTA, Cron and retirement gates are observed. The owner asked for one large PR; issue #418 and `RELEASING.md` specify a `develop` → `main` release PR, so the PR target and gate exception must be explicit before merge. Production SQL was applied under the protected gate described below; Edge deployment, Cron and EAS remain unchanged. On October 21, the owner disabled Railway Auto Deploy on `api`, `reminders`, and `pool-topup` while leaving the current services and schedules running; the pasted `api` settings explicitly show it disabled. diff --git a/supabase/functions/api/handler.test.mjs b/supabase/functions/api/handler.test.mjs index 6b08476c..768ba521 100644 --- a/supabase/functions/api/handler.test.mjs +++ b/supabase/functions/api/handler.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; +import { runInNewContext } from 'node:vm'; import { createHandler } from './handler.ts'; import './avatar.test.mjs'; @@ -14,6 +15,169 @@ const config = { const request = (path, init = {}) => new Request(`https://one-concept.supabase.co/functions/v1/api${path}`, init); const editorialToken = (claims) => `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`; +test('public cutover pages serve browser HTML without leaking service credentials', async () => { + const calls = []; + const handler = createHandler(config, async (...args) => { + calls.push(args); + throw new Error('Public pages must not use a service request'); + }); + for (const path of ['/confirmed', '/reset-password', '/privacy']) { + const result = await handler(request(path)); + const body = await result.text(); + assert.equal(result.status, 200); + assert.match(result.headers.get('content-type'), /^text\/html/); + assert.equal(result.headers.get('cache-control'), 'no-store'); + assert.equal(result.headers.get('referrer-policy'), 'no-referrer'); + assert.equal(result.headers.get('x-robots-tag'), 'noindex, nofollow, noarchive'); + assert.match(result.headers.get('content-security-policy'), /frame-ancestors 'none'/); + assert.doesNotMatch(body, /private-service-key|__NONCE__|__CONFIG__/); + } + assert.equal(calls.length, 0); + const reset = await (await handler(request('/reset-password'))).text(); + assert.match(reset, /\/auth\/v1\/user/); + assert.match(reset, /public-anon-key/); + assert.match(reset, /history\.replaceState\(null, '', location\.pathname\)/); + const rejected = await handler(request('/reset-password', { method: 'POST' })); + assert.equal(rejected.status, 405); + assert.equal(rejected.headers.get('allow'), 'GET'); +}); + +test('confirmation page claims success only after an Auth redirect', async () => { + const handler = createHandler(config); + const body = await (await handler(request('/confirmed'))).text(); + const script = body.match(/`; + +const RESET = ` + +One Concept — reset password +

Reset your password

+

Choose a new password for your One Concept account.

+ +
`; + +// Keep this learner-facing disclosure aligned with backend/app/privacy.py. +const PRIVACY = ` + +One Concept — Privacy Policy

Privacy Policy

Effective: October 1, 2026

+

One Concept is operated by Coding Moves. This policy explains the data used to provide the app and how to contact us about it.

+

Information used to provide the app

+

How we use information

We use this information to sign you in, select and remember learning activities, calculate streaks, keep saved and liked content, and send enabled reminders. We do not use the app for advertising or include behavioural analytics or crash-reporting SDKs.

+

Optional public profile

Profile sharing is off by default. If enabled, anyone with your link or QR code sees only the display name, streaks, learned-concept count and achievements you select. Your email, saved concepts, account identifiers and private activity are excluded. Turning sharing off revokes the link, but cannot erase screenshots already made.

+

Optional Connections

Connections stores requests, accepted relationships and blocks for your private lists. Receiving requests is off by default. Other learners see only the profile information you currently share. You can decline, cancel, remove or block a connection. Turn off profile sharing to revoke a public link. Limited request-event timestamps enforce invitation limits.

+

Service providers

Supabase provides authentication, database and API infrastructure. Expo delivers enabled push notifications. These providers process the information needed to run the app.

+

Choices and retention

You can turn reminders off in the app. Signing out removes account-scoped app caches and attempts to deregister the current device while online. Account and learning data remain associated with your account until you request deletion or we no longer need them to run the service.

+

Security

Private requests use a verified account token, and access controls are designed to limit data to the signed-in account. No internet transmission or storage method is completely secure.

+

Your requests and contact

Request access to, correction of, or deletion of your account data by emailing contactmuawia@gmail.com from your account address. We may need to verify the request.

+

Changes

We may update this policy as data practices change. The current version is published at this address with its effective date.

+
`; + +function configLiteral(config: PageConfig): string { + return JSON.stringify({ url: config.supabaseUrl.replace(/\/+$/, ''), anonKey: config.anonKey }) + .replace(/[<>&\u2028\u2029]/g, character => + `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`); +} + +export function publicPage(path: string, method: string, config: PageConfig, cors: Headers): Response | null { + if (!['/confirmed', '/reset-password', '/privacy'].includes(path)) return null; + if (method !== 'GET') { + const headers = new Headers(cors); + headers.set('content-type', 'application/json; charset=utf-8'); + headers.set('allow', 'GET'); + return new Response(JSON.stringify({ detail: 'Method Not Allowed' }), { status: 405, headers }); + } + const nonce = crypto.randomUUID().replaceAll('-', ''); + const template = path === '/confirmed' ? CONFIRMED : path === '/privacy' ? PRIVACY : RESET; + const body = template.replaceAll('__NONCE__', nonce) + .replace('__CONFIG__', () => configLiteral(config)); + const headers = new Headers(cors); + headers.set('content-type', 'text/html; charset=utf-8'); + headers.set('cache-control', 'no-store'); + headers.set('x-content-type-options', 'nosniff'); + headers.set('referrer-policy', 'no-referrer'); + headers.set('x-frame-options', 'DENY'); + headers.set('x-robots-tag', 'noindex, nofollow, noarchive'); + headers.set('content-security-policy', `default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; connect-src ${config.supabaseUrl}`); + return new Response(body, { status: 200, headers }); +}