From 60bcf61b0dae6075d18687a6e5888b60f58df573 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 25 May 2026 12:51:33 -0400 Subject: [PATCH 01/21] chore: adopt branch-scoped session logs --- .github/scripts/pr-version-bump.mjs | 97 +++++++++++++++++++ .github/workflows/pr-version-bump.yml | 52 ++++++++++ AGENTS.md | 14 ++- agent-context/session-log/README.md | 32 ++++++ .../{ => session-log/archive}/session-log.md | 0 agent-context/session-log/dev.md | 10 ++ 6 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/pr-version-bump.mjs create mode 100644 .github/workflows/pr-version-bump.yml create mode 100644 agent-context/session-log/README.md rename agent-context/{ => session-log/archive}/session-log.md (100%) create mode 100644 agent-context/session-log/dev.md diff --git a/.github/scripts/pr-version-bump.mjs b/.github/scripts/pr-version-bump.mjs new file mode 100644 index 0000000..114a7d8 --- /dev/null +++ b/.github/scripts/pr-version-bump.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const args = process.argv.slice(2); +const base = args.includes("--base") ? args[args.indexOf("--base") + 1] : process.env.GITHUB_BASE_REF; +const dryRun = args.includes("--dry-run"); + +if (!base || !["dev", "main"].includes(base)) { + console.error("Usage: node .github/scripts/pr-version-bump.mjs --base [--dry-run]"); + process.exit(1); +} + +function git(args, fallback = "") { + try { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + return fallback; + } +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeJson(file, data) { + fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n"); +} + +function bump(version) { + const parts = String(version || "0.0.0").split(".").map((part) => Number.parseInt(part, 10)); + const minor = Number.isFinite(parts[1]) ? parts[1] : 0; + const patch = Number.isFinite(parts[2]) ? parts[2] : 0; + if (base === "main") return `0.${minor + 1}.0`; + return `0.${minor}.${patch + 1}`; +} + +function baseJson(file) { + const fromGit = git(["show", `origin/${base}:${file}`], ""); + if (!fromGit) return null; + try { + return JSON.parse(fromGit); + } catch { + return null; + } +} + +function packageCandidates() { + const changed = git(["diff", "--name-only", `origin/${base}...HEAD`], "") + .split("\n") + .filter(Boolean); + const candidates = new Set(); + for (const file of changed) { + const appMatch = file.match(/^apps\/([^/]+)\//); + if (appMatch && fs.existsSync(path.join("apps", appMatch[1], "package.json"))) { + candidates.add(path.join("apps", appMatch[1], "package.json")); + } + if (file.startsWith("app/") && fs.existsSync(path.join("app", "package.json"))) { + candidates.add(path.join("app", "package.json")); + } + } + if (candidates.size === 0 && fs.existsSync("package.json")) candidates.add("package.json"); + if (candidates.size === 0 && fs.existsSync(path.join("app", "package.json"))) candidates.add(path.join("app", "package.json")); + return [...candidates]; +} + +function updatePackage(file) { + const current = readJson(file); + const basePkg = baseJson(file) || current; + const nextVersion = bump(basePkg.version); + if (current.version === nextVersion) { + console.log(`${file} already at ${nextVersion}`); + return; + } + console.log(`${file}: ${current.version || "(none)"} -> ${nextVersion}`); + if (!dryRun) { + current.version = nextVersion; + writeJson(file, current); + } + + const lockFile = path.join(path.dirname(file), "package-lock.json"); + if (fs.existsSync(lockFile)) { + const lock = readJson(lockFile); + if (lock.name === current.name || path.dirname(file) !== ".") lock.version = nextVersion; + if (lock.packages && lock.packages[""]) lock.packages[""].version = nextVersion; + if (!dryRun) writeJson(lockFile, lock); + } +} + +const candidates = packageCandidates(); +if (candidates.length === 0) { + console.error("No package.json candidate found for version bump."); + process.exit(1); +} + +for (const candidate of candidates) updatePackage(candidate); diff --git a/.github/workflows/pr-version-bump.yml b/.github/workflows/pr-version-bump.yml new file mode 100644 index 0000000..1ce9e7c --- /dev/null +++ b/.github/workflows/pr-version-bump.yml @@ -0,0 +1,52 @@ +name: PR Version Bump + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - dev + - main + +permissions: + contents: write + pull-requests: read + +jobs: + version-bump: + if: ${{ github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - name: Reject forked PR writeback + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + run: | + echo "PR version bump writes back to same-repo branches only. Please apply the expected package.json version bump manually for forked PRs." + exit 1 + + - name: Check out PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Fetch base branch + run: git fetch origin ${{ github.base_ref }} --depth=1 + + - name: Apply expected version bump + run: node .github/scripts/pr-version-bump.mjs --base ${{ github.base_ref }} + + - name: Commit version bump + run: | + if git diff --quiet; then + echo "Version already matches expected bump." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package.json package-lock.json app/package.json app/package-lock.json apps/*/package.json apps/*/package-lock.json 2>/dev/null || true + if git diff --cached --quiet; then + echo "No package version files were staged." + exit 0 + fi + git commit -m "chore: bump PR version" + git push diff --git a/AGENTS.md b/AGENTS.md index 1a08a29..f652e25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,6 @@ Source files are organized under the `app/` directory. Agent workflow artefacts ``` .github/workflows/ # PR template + CI workflows for build, lint, supabase, secret scanning agent-context/ # logs and recurring agent workflow context -├─ session-log.md # Mandatory - append-only per session, new entries at top of file, v ↑ per edit ├─ db-workflow.md # CI/CD overview for database changes ├─ app-context.md # Problem, approach, value prop ├─ style-guide.md # Style for the wallet app @@ -48,7 +47,6 @@ services/ # Node service modules and worker entrypoints for index styles/ # global styles supabase/ ├─ sql-schema.sql # latest sql schema (github action runs db pull on PR) -├─ migrations/ # Agent-generated incremental SQL patches (file name starts with session version referencing session-log.md) └─ functions/ # Supabase Edge Functions and shared Deno-safe helpers ├─ _shared/ # Shared auth, app scoping, RBAC, validation and domain helpers └─ */ # Domain edge functions (for example user-settings, bia-service, store-operations) @@ -92,7 +90,6 @@ AGENTS.md # this file * **Pull Requests** * Keep pull request descriptions concise and reviewer-focused. * If any related issues are known, mention them in PR (e.g. "Issues: #10, #11") -* **Session version tags** * Follow SemVer (v2.0.0-alpha) * Major IDs ("v1.0", "v2.0") mirror new feature branches * Minor IDs ("v2.0", "v2.1") mirror different coding sesssions on the same feature branch @@ -118,9 +115,18 @@ Agents **must** read and follow `workflow.md` each time. * SQL migrations must be **idempotent & reversible** where practical (include rollback notes or a `-- DOWN` section; each DROP/ALTER should be preceded by IF EXISTS/IF NOT EXISTS where supported). * Pre-commit hook **warns** (not blocks) if ESLint and similar (e.g. in Foundry) cannot start. -* Session log must bump version +1 for each session * All new external calls must use SafeERC20 / Address.functionCall and be covered in tests. * Agents must **never** directly modify the linked Supabase database. Only a human may run commands that target the linked database and change its state. * Agents must always stop and ask for explicit human permission before proposing or running any Supabase command that could change the linked database, including `supabase db push`, `supabase migration up`, `supabase db reset --linked`, `supabase link`, or any `supabase --linked` write operation. * This repo qualifies for the stricter multi-frontend Supabase boundary rule: app-facing wallet/sparechange/contracts flows should use typed Edge Functions, narrow RPCs, read-model helpers, or documented server boundaries rather than broad direct browser/client table access. Existing direct-access compatibility paths should be treated as cleanup candidates unless `docs/engineering/supabase-boundary-contract.md` and `scripts/check-no-direct-supabase-db.mjs` explicitly allow them. * Deployed Next.js/Vercel runtime should not require `SUPABASE_SERVICE_ROLE_KEY`. That key belongs only in privileged Supabase Edge Functions and the external indexer/onramp worker/runtime paths that intentionally need service-role access. + +## Branch-Scoped Session Logs And Todos + +- Use `agent-context/session-log/` for active session logs. Do not add new entries to legacy `agent-context/session-log.md` files. +- For feature branches, update the branch log immediately before each commit. Use `YYYY-MM-DD-featurebranch.md` in single-app repos and `YYYY-MM-DD-app-featurebranch.md` in monorepos. +- Direct work on `main` may use `agent-context/session-log/main.md`; direct work on `dev` may use `agent-context/session-log/dev.md`. +- Each entry must include UTC timestamp, agent, branch, head, summary, validation, and follow-ups. +- Keep `agent-context/todo.md` focused on roadmap items and active follow-ups. Completed work belongs in the current branch log. +- See `agent-context/session-log/README.md` for naming and archival rules. + diff --git a/agent-context/session-log/README.md b/agent-context/session-log/README.md new file mode 100644 index 0000000..61c57f2 --- /dev/null +++ b/agent-context/session-log/README.md @@ -0,0 +1,32 @@ +# Session Logs + +This folder contains branch-scoped session logs. Use it instead of a single long-running `agent-context/session-log.md` file. + +## File Naming + +- Feature branches in single-app repos use `YYYY-MM-DD-featurebranch.md`. +- Feature branches in monorepos use `YYYY-MM-DD-app-featurebranch.md`. +- Direct work on `main` uses `main.md`. +- Direct work on `dev` uses `dev.md`. +- The date is the best-known branch creation date: local reflog date first, first branch-only commit date second, current date last. +- Slug branch names by replacing `/` and unsafe path characters with `-`. + + +## Entry Format + +Add or update the current branch log immediately before each commit. Keep entries concise, factual, and easy to merge. + +```md +## 2026-05-25T00:00:00.000Z - Short session title + +- agent: Codex +- branch: feature/example +- head: abc1234 +- summary: What changed and why. +- validation: Commands or checks run, or "Not run (...)" with the reason. +- follow-ups: Remaining work, or "None". +``` + +## Legacy History + +Older monolithic logs are preserved in `agent-context/session-log/archive/`. Treat archived logs as historical audit material, not the active logging location. diff --git a/agent-context/session-log.md b/agent-context/session-log/archive/session-log.md similarity index 100% rename from agent-context/session-log.md rename to agent-context/session-log/archive/session-log.md diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md new file mode 100644 index 0000000..448e8ff --- /dev/null +++ b/agent-context/session-log/dev.md @@ -0,0 +1,10 @@ +# Session Log - dev + +## 2026-05-25T16:49:36.722Z - Branch-scoped session-log migration + +- agent: Codex +- branch: dev +- head: 02caea9 +- summary: Migrated this repo to branch-scoped session logs, archived the legacy monolithic log when present, documented the new workflow, and added PR version-bump automation. +- validation: Generated migration artifacts were inspected by script; run `node .github/scripts/pr-version-bump.mjs --base dev --dry-run` and `node .github/scripts/pr-version-bump.mjs --base main --dry-run` after migration. +- follow-ups: Future agents should append new entries to this branch log before each commit and keep completed work out of archived legacy logs. From 68908b5143b27d9609167b9037031aa30a25142f Mon Sep 17 00:00:00 2001 From: Noak Date: Thu, 16 Jul 2026 14:05:05 -0400 Subject: [PATCH 02/21] docs(backlog): mark todo superseded by GitHub issues Objective: - move active backlog tracking from repo-local todos to GitHub Issues and the owner-level project Changes: - mark agent-context/todo.md as superseded by GitHub Issues - link migrated/open issues #57, #59, #63, #74, #75, and #76 - add a dev branch session-log entry for the backlog migration Validation: - gh issue create for #74, #75, #76 - gh project item-add and item-edit for #57, #59, #63, #74, #75, #76 - gh project item-list 1 --owner GreenPill-TO --format json --limit 20 - git diff --check Notes: - issues are intentionally left in Inbox and not yet aligned to features, sprints, or goals --- agent-context/session-log/dev.md | 9 +++++++++ agent-context/todo.md | 22 ++++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 448e8ff..52e6c0d 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-16T17:54:29Z - Migrate active todos to GitHub Issues + +- agent: Codex +- branch: dev +- head: 60bcf61 +- summary: Created GitHub issues for the remaining repo-local open/not-started todos, added them and existing unresolved roadmap issues to the `GreenPill Project` with `Status = Inbox`, and marked `agent-context/todo.md` as superseded by GitHub Issues for active backlog tracking. +- validation: `gh issue create` for #74, #75, #76; `gh project item-add` and `gh project item-edit` for #57, #59, #63, #74, #75, #76; `gh project item-list 1 --owner GreenPill-TO --format json --limit 20`. +- follow-ups: Reconcile stale issue #63 against the completed P1 service-role work, then align Inbox issues into Features/Sprints/Goals when ready. + ## 2026-05-25T16:49:36.722Z - Branch-scoped session-log migration - agent: Codex diff --git a/agent-context/todo.md b/agent-context/todo.md index 9788188..d1031ce 100644 --- a/agent-context/todo.md +++ b/agent-context/todo.md @@ -1,5 +1,19 @@ # Todo +> Superseded by GitHub Issues as of 2026-07-16. +> +> Per `AGENTS.md`, GitHub Issues plus the owner-level `GreenPill Project` are now the active roadmap/backlog. Keep this file as historical/reference context only. Do not add new active work here unless explicitly asked to restore repo-local todo tracking. +> +> Active open work migrated or confirmed in GitHub: +> - [#57](https://github.com/GreenPill-TO/Genero/issues/57) `refactor(edge): move remaining backend interactions behind Supabase edge functions` +> - [#59](https://github.com/GreenPill-TO/Genero/issues/59) `Refactor TorontoCoin suite toward upgradeable core contracts and reduce helper sprawl` +> - [#63](https://github.com/GreenPill-TO/Genero/issues/63) `Reduce broad SUPABASE_SERVICE_ROLE_KEY usage in production request paths` +> - [#74](https://github.com/GreenPill-TO/Genero/issues/74) `Finish TCOIN remote/deployment release environment alignment` +> - [#75](https://github.com/GreenPill-TO/Genero/issues/75) `Consolidate passcode send/verify behind a shared server boundary` +> - [#76](https://github.com/GreenPill-TO/Genero/issues/76) `Continue optional post-P1 Supabase privileged-boundary hardening` +> +> All listed issues were added to the owner-level `GreenPill Project` with `Status = Inbox`. They have not yet been aligned to Features, Sprints, or Goals. + ## Production Readiness Priorities - [x] `P0` Security hardening for wallet runtime flows: @@ -56,9 +70,9 @@ - Head: `a59fed5` - Session-log reference(s): `v1.216` -- [ ] `P1` Remote/deployment release environment alignment: +- [x] `P1` Remote/deployment release environment alignment: CI-assisted alignment is now available through `.github/workflows/release-alignment-tcoin.yml`: after successful migration deploys on `dev`/`main`, or by manual dispatch, it reloads PostgREST, runs deployment-profile wallet preflight, runs TorontoCoin ops checks, and optionally runs browser smoke when `SMOKE_BASE_URL` is configured. Remaining close-out: configure the Preview/Production GitHub Environment secrets/vars, confirm Vercel envs and retired aliases, run the workflow green for the intended remote target, confirm Data API schema exposure for `public`, `storage`, `graphql_public`, `indexer`, and `chain_data`, keep Buy TCOIN disabled unless fully smoke-tested, and manually verify signed-in/OTP/pay-link/worker scheduler paths that CI cannot yet prove. - - Status: In progress + - Status: Superseded by GitHub issue [#74](https://github.com/GreenPill-TO/Genero/issues/74); not completed here - Timestamp started: 2026-04-29 15:33 EDT - Timestamp completed: TBD - Feature branch: `codex/edge-privileged-boundary-hardening` @@ -116,7 +130,7 @@ Consider a Supabase Edge Function or a common server-side helper that both wallet and SpareChange can reuse. Preserve the current post-OTP requirement that verification returns or establishes a usable session quickly enough for the browser auth bootstrap to reuse the fresh access token without reintroducing the earlier `401` race. If this is pursued, carry over the stronger error-handling and test coverage ideas from the older `codex/fix-toast-error-on-signup-modal` branch rather than reviving that branch wholesale. - - Status: Not started + - Status: Superseded by GitHub issue [#75](https://github.com/GreenPill-TO/Genero/issues/75); not started here - Timestamp started: TBD - Timestamp completed: TBD - Feature branch: TBD @@ -125,7 +139,7 @@ - Continue optional post-P1 Supabase boundary hardening by splitting remaining intentionally privileged Edge operations into narrower domain RPCs or smaller functions where product risk justifies the complexity. Priority candidates are user-settings custody/profile writes, payment-link privileged resolve/consume, governance action-feed reads, voucher-runtime payment records, merchant/admin mutations, BIA/admin mutation surfaces, and onramp settlement/admin paths. - - Status: Not started + - Status: Superseded by GitHub issue [#76](https://github.com/GreenPill-TO/Genero/issues/76); not started here - Timestamp started: TBD - Timestamp completed: TBD - Feature branch: TBD From d453f70a2aebfe43f24ef01c8d27f23e00df2b5d Mon Sep 17 00:00:00 2001 From: Noak Date: Thu, 16 Jul 2026 14:05:32 -0400 Subject: [PATCH 03/21] chore(supabase): align local Colima startup defaults Objective: - keep local Supabase startup aligned with the shared Colima agent runtime Changes: - default the local Supabase helper to DOCKER_CONTEXT=colima-agents and clear DOCKER_HOST - document the shared Colima context in README local setup guidance - set local Supabase Postgres major version to 17 and disable local analytics - add a dev branch session-log entry for the local startup alignment Validation: - bash -n scripts/start-local-supabase.sh - git diff --check Notes: - did not start or mutate any linked Supabase project --- README.md | 2 +- agent-context/session-log/dev.md | 9 +++++++++ scripts/start-local-supabase.sh | 5 ++--- supabase/config.toml | 4 ++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5f1479f..497e91c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ For local Supabase work under Colima, start the trimmed local stack with: ```bash pnpm supabase:start:local ``` -That helper switches to the `colima-varrun` Docker context when available and reapplies the GoTrue mailer-host patch needed to suppress local `GOTRUE_MAILER_EXTERNAL_HOSTS` warnings for browser and gateway traffic (`localhost`, `127.0.0.1`, and the local `kong` gateway host). +That helper uses the shared `colima-agents` Docker context and reapplies the GoTrue mailer-host patch needed to suppress local `GOTRUE_MAILER_EXTERNAL_HOSTS` warnings for browser and gateway traffic (`localhost`, `127.0.0.1`, and the local `kong` gateway host). Open `http://localhost:3000` in your browser. Next.js will serve the app configured by `NEXT_PUBLIC_CITYCOIN` and `NEXT_PUBLIC_APP_NAME`. diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 52e6c0d..93269b2 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-16T18:05:12Z - Align local Supabase Colima defaults + +- agent: Codex +- branch: dev +- head: 68908b5 +- summary: Updated the local Supabase startup helper to default to the shared `colima-agents` Docker context, clear `DOCKER_HOST`, document that context in the README, and align local Supabase config with Postgres 17 plus disabled local analytics. +- validation: `bash -n scripts/start-local-supabase.sh`; `git diff --check`. +- follow-ups: None. + ## 2026-07-16T17:54:29Z - Migrate active todos to GitHub Issues - agent: Codex diff --git a/scripts/start-local-supabase.sh b/scripts/start-local-supabase.sh index e43bc36..7e11b90 100644 --- a/scripts/start-local-supabase.sh +++ b/scripts/start-local-supabase.sh @@ -28,9 +28,8 @@ auth_container="supabase_auth_${project_name}" network_name="supabase_network_${project_name}" default_excludes=(-x storage-api,imgproxy,logflare,vector,studio) -if docker context ls --format '{{.Name}}' | grep -qx 'colima-varrun'; then - docker context use colima-varrun >/dev/null -fi +export DOCKER_CONTEXT="${DOCKER_CONTEXT:-colima-agents}" +unset DOCKER_HOST if [ "$#" -eq 0 ]; then supabase_args=("${default_excludes[@]}") diff --git a/supabase/config.toml b/supabase/config.toml index fbcbbe7..51a830d 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -12,6 +12,7 @@ max_rows = 1000 [db] port = 55422 shadow_port = 55420 +major_version = 17 [db.seed] enabled = true @@ -31,3 +32,6 @@ additional_redirect_urls = [ "http://127.0.0.1:3001", ] enable_signup = true + +[analytics] +enabled = false From 859b7f18828947dcaab5430776f6ed40dbebacd5 Mon Sep 17 00:00:00 2001 From: Noak Date: Thu, 16 Jul 2026 15:23:58 -0400 Subject: [PATCH 04/21] chore(release): record sprint env vetting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Objective: - capture Sprint #78 vetting and the first TCOIN release-alignment environment check before continuing implementation Changes: - add a branch-scoped session-log entry for Sprint #78 vetting - record GitHub Environment variable/secret-name validation for Task #82 - document remaining environment-specific Supabase secret follow-ups Validation: - git diff --check - gh variable list --env Preview – tcoin / Production – tcoin - gh secret list --env Preview – tcoin / Production – tcoin Notes: - no linked Supabase writes were run - no secret values were printed or committed --- agent-context/session-log/dev.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 93269b2..f0c81bd 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-16T19:22:38Z - Vet Sprint #78 and start release env alignment + +- agent: Codex +- branch: codex/tcoin-production-launch-readiness +- head: d453f70 +- summary: Vetted Sprint #78 into Ready executable Goals/Tasks, created the shared implementation branch, added vetting/worktree notes and dependency edges, and started Task #82 by confirming GitHub Environment variable/secret name alignment for `Preview – tcoin` and `Production – tcoin`. Added `SMOKE_BASE_URL` as a non-secret GitHub Environment variable for both TCOIN environments. +- validation: `gh project item-list 1 --owner GreenPill-TO --format json --limit 1000`; `gh variable list --env "Preview – tcoin"`; `gh variable list --env "Production – tcoin"`; `gh secret list --env "Preview – tcoin"`; `gh secret list --env "Production – tcoin"`. +- follow-ups: Task #82 still needs environment-specific Supabase deploy secrets set in GitHub Environments: `SUPABASE_SESSION_POOLER_TCOIN_PREVIEW`, `SUPABASE_ACCESS_TOKEN_TCOIN_PREVIEW`, `SUPABASE_SESSION_POOLER_TCOIN_PRODUCTION`, and `SUPABASE_ACCESS_TOKEN_TCOIN_PRODUCTION`. GitHub does not expose existing repo-level fallback secret values for copying. + ## 2026-07-16T18:05:12Z - Align local Supabase Colima defaults - agent: Codex From 8c7c9384e45f54eaa78ba75abff417beee3d5576 Mon Sep 17 00:00:00 2001 From: Noak Date: Thu, 16 Jul 2026 15:25:46 -0400 Subject: [PATCH 05/21] chore(release): record vercel env blocker Objective: - document Task #83 Vercel environment confirmation evidence and the exact remaining auth/tooling blocker Changes: - add a branch-scoped session-log entry for the TCOIN Vercel env confirmation attempt - record the available PR/Vercel deployment evidence and missing local Vercel access path Validation: - git diff --check - vercel --version failed with command not found - pnpm exec vercel --version failed with command not found - checked process/local env profiles and Vercel auth/project metadata paths Notes: - no Vercel env values were changed - no linked Supabase writes were run --- agent-context/session-log/dev.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index f0c81bd..16df6b4 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -9,6 +9,15 @@ - validation: `gh project item-list 1 --owner GreenPill-TO --format json --limit 1000`; `gh variable list --env "Preview – tcoin"`; `gh variable list --env "Production – tcoin"`; `gh secret list --env "Preview – tcoin"`; `gh secret list --env "Production – tcoin"`. - follow-ups: Task #82 still needs environment-specific Supabase deploy secrets set in GitHub Environments: `SUPABASE_SESSION_POOLER_TCOIN_PREVIEW`, `SUPABASE_ACCESS_TOKEN_TCOIN_PREVIEW`, `SUPABASE_SESSION_POOLER_TCOIN_PRODUCTION`, and `SUPABASE_ACCESS_TOKEN_TCOIN_PRODUCTION`. GitHub does not expose existing repo-level fallback secret values for copying. +## 2026-07-16T19:30:12Z - Record Vercel env confirmation blocker + +- agent: Codex +- branch: codex/tcoin-production-launch-readiness +- head: 859b7f1 +- summary: Started Task #83, confirmed the TCOIN Preview deployment URL from PR #77's Vercel comment and repo-level launch env docs, then documented the remaining blocker: this shell has no Vercel CLI, cached auth, local project link, or token that would allow direct Vercel env metadata inspection. +- validation: `vercel --version` failed with command not found; `pnpm exec vercel --version` failed with command not found; checked `printenv` and local env profiles for `VERCEL*`; checked `~/.vercel` and `.vercel/project.json`; inspected PR #77 Vercel deployment comment. +- follow-ups: A Vercel-authenticated operator or agent session must inspect TCOIN Preview/Production env metadata directly, or provide/install a Vercel token/CLI login, before Task #83 can claim full Vercel env confirmation. + ## 2026-07-16T18:05:12Z - Align local Supabase Colima defaults - agent: Codex From abf8c97f1f49ae60678f975bab144edecbd54e40 Mon Sep 17 00:00:00 2001 From: Noak Date: Thu, 16 Jul 2026 15:27:46 -0400 Subject: [PATCH 06/21] chore(release): record remote supabase blocker Objective: - capture Task #84 remote Supabase readiness checks and the external DNS blocker before stopping the sprint implementation loop Changes: - add a branch-scoped session-log entry for the remote Supabase preflight attempt - record failed read-only wallet preflight, TorontoCoin ops, pool compatibility, fetch, and DNS checks Validation: - git diff --check - pnpm ops:wallet:preflight:supabase-remote failed with fetch failed - pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin failed with fetch failed - pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin:pools failed with fetch failed - direct Node DNS/fetch probe failed with getaddrinfo ENOTFOUND Notes: - no linked Supabase writes were run - remote Supabase profile URL must be corrected or the project restored before continuing hosted release smoke --- agent-context/session-log/dev.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 16df6b4..96e95e8 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -18,6 +18,15 @@ - validation: `vercel --version` failed with command not found; `pnpm exec vercel --version` failed with command not found; checked `printenv` and local env profiles for `VERCEL*`; checked `~/.vercel` and `.vercel/project.json`; inspected PR #77 Vercel deployment comment. - follow-ups: A Vercel-authenticated operator or agent session must inspect TCOIN Preview/Production env metadata directly, or provide/install a Vercel token/CLI login, before Task #83 can claim full Vercel env confirmation. +## 2026-07-16T19:36:45Z - Record remote Supabase DNS blocker + +- agent: Codex +- branch: codex/tcoin-production-launch-readiness +- head: 8c7c938 +- summary: Started Task #84 and ran the read-only remote Supabase release checks. The wallet remote preflight, TorontoCoin ops check, pool compatibility check, direct fetch probe, and DNS lookup all failed before schema/RPC validation because the configured `.env.local-supabase-remote` Supabase host does not resolve. +- validation: `pnpm ops:wallet:preflight:supabase-remote` failed with `Wallet release health RPC failed: TypeError: fetch failed`; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin` failed with `TypeError: fetch failed`; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin:pools` failed with `TypeError: fetch failed`; direct Node fetch/DNS probe failed with `getaddrinfo ENOTFOUND`. +- follow-ups: Confirm or replace the remote Supabase project URL in `.env.local-supabase-remote`, or restore/unpause the intended Supabase project, before remote schema/API alignment and hosted smoke can proceed. + ## 2026-07-16T18:05:12Z - Align local Supabase Colima defaults - agent: Codex From 888ca50d5cea2c4fb130aece8dc39a81c52c6468 Mon Sep 17 00:00:00 2001 From: Noak Date: Sun, 19 Jul 2026 05:54:15 -0400 Subject: [PATCH 07/21] chore(release): record restored supabase checks Objective: - update Task #84 evidence after the remote Supabase projects were restored Changes: - add a branch-scoped session-log entry for restored remote release checks - record that DNS/reachability is fixed but release blockers remain Validation: - git diff --check - pnpm ops:wallet:preflight:supabase-remote reached release-health RPC and failed on release blockers - pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin reached ops checks and failed on indexer tracking blockers - pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin:pools reached pool checks and failed on indexed visibility blockers Notes: - no linked Supabase writes were run - pg_cron/cleanup cron and indexer visibility fixes need explicit operator approval before continuing --- agent-context/session-log/dev.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 96e95e8..5553ad3 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -27,6 +27,15 @@ - validation: `pnpm ops:wallet:preflight:supabase-remote` failed with `Wallet release health RPC failed: TypeError: fetch failed`; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin` failed with `TypeError: fetch failed`; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin:pools` failed with `TypeError: fetch failed`; direct Node fetch/DNS probe failed with `getaddrinfo ENOTFOUND`. - follow-ups: Confirm or replace the remote Supabase project URL in `.env.local-supabase-remote`, or restore/unpause the intended Supabase project, before remote schema/API alignment and hosted smoke can proceed. +## 2026-07-19T09:53:18Z - Re-run restored remote Supabase readiness + +- agent: Codex +- branch: codex/tcoin-production-launch-readiness +- head: abf8c97 +- summary: Re-ran Task #84 after the Supabase projects were restored. The prior DNS blocker is resolved and remote release-health reads now return structured data, but remote readiness remains blocked by missing `pg_cron`, missing `wallet-payment-request-links-cleanup` cron, indexer run status `error`, and missing required TCOIN/pool indexer visibility. +- validation: `pnpm ops:wallet:preflight:supabase-remote` reached the RPC and failed with release blockers; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin` reached chain/ops checks and failed on indexer tracking blockers; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin:pools` reached pool compatibility checks and failed on tracked-pool/indexed visibility blockers. +- follow-ups: Fixing the remaining remote blockers requires explicit linked Supabase/operator approval for `pg_cron`/cleanup cron configuration and follow-up indexer/scheduler work before Preview smoke should proceed. + ## 2026-07-16T18:05:12Z - Align local Supabase Colima defaults - agent: Codex From 41b65e70ebfc9cdf7e369946d65b61a7c44c6c94 Mon Sep 17 00:00:00 2001 From: Noak Date: Sun, 19 Jul 2026 20:55:23 -0400 Subject: [PATCH 08/21] chore(release): repair preview tcoin readiness checks --- agent-context/session-log/dev.md | 9 +++ scripts/torontocoin-ops-check.ts | 1 + .../torontocoin-pool-compatibility-check.ts | 1 + shared/lib/indexer/statusReadModel.test.ts | 67 +++++++++++++++++++ shared/lib/indexer/statusReadModel.ts | 27 ++++++-- 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 5553ad3..bafe903 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T00:55:12Z - Repair Preview Supabase release alignment + +- agent: Codex +- branch: codex/tcoin-production-launch-readiness +- head: 888ca50 +- summary: After explicit operator approval for Preview TCOIN linked Supabase changes only, repaired the `GeneroDev` remote release-health blockers by enabling `pg_cron`, scheduling `wallet-payment-request-links-cleanup`, exposing `indexer`/`chain_data` through PostgREST config, and repairing TCOIN indexer metadata visibility for the two expected pools. Also fixed the local TorontoCoin ops read-model check so live chain-discovered pools are compared against remote indexer pool details instead of only the static bootstrap pool. +- validation: `pnpm ops:wallet:preflight:supabase-remote` passed with no blockers and expected warnings for `development`, dormant onramp secrets, and no recent cron run details; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin` passed with no release blockers; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin:pools` passed with no release blockers; `pnpm exec vitest run shared/lib/indexer/statusReadModel.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `pnpm test`; `git diff --check`. +- follow-ups: The one-shot indexer queue worker still timed out during manual Preview repair and should not be scheduled until Goal #80 inspects/fixes bounded worker runtime behaviour. Production Supabase was not changed. Vercel env confirmation and deployment-profile smoke remain separate launch-readiness tasks. + ## 2026-07-16T19:22:38Z - Vet Sprint #78 and start release env alignment - agent: Codex diff --git a/scripts/torontocoin-ops-check.ts b/scripts/torontocoin-ops-check.ts index 119702f..8712779 100644 --- a/scripts/torontocoin-ops-check.ts +++ b/scripts/torontocoin-ops-check.ts @@ -53,6 +53,7 @@ async function main() { citySlug: "tcoin", chainId: opsStatus.addresses.chainId, requiredTokenAddress: opsStatus.addresses.cplTcoin, + expectedTorontoCoinPools: opsStatus.pools, }); const blockers = collectReleaseBlockers({ opsStatus, diff --git a/scripts/torontocoin-pool-compatibility-check.ts b/scripts/torontocoin-pool-compatibility-check.ts index 9550dba..3f5bbfe 100644 --- a/scripts/torontocoin-pool-compatibility-check.ts +++ b/scripts/torontocoin-pool-compatibility-check.ts @@ -16,6 +16,7 @@ async function main() { citySlug: "tcoin", chainId: status.addresses.chainId, requiredTokenAddress: status.addresses.cplTcoin, + expectedTorontoCoinPools: status.pools, }); const poolSummaries = status.pools.map((pool) => { diff --git a/shared/lib/indexer/statusReadModel.test.ts b/shared/lib/indexer/statusReadModel.test.ts index 14790c5..d3e70fb 100644 --- a/shared/lib/indexer/statusReadModel.test.ts +++ b/shared/lib/indexer/statusReadModel.test.ts @@ -121,6 +121,73 @@ describe("getIndexerScopeStatusReadModel", () => { expect("activePoolDetails" in status).toBe(false); }); + it("can enrich live TorontoCoin pools that are not in the static bootstrap runtime", async () => { + const rpc = vi.fn().mockResolvedValue({ + data: { + scopeKey: "tcoin:42220", + citySlug: "tcoin", + chainId: 42220, + runControl: null, + checkpoints: [], + activePoolCount: 2, + activeTokenCount: 3, + biaSummary: { + activeBias: 0, + mappedPools: 0, + unmappedPools: 0, + staleMappings: 0, + componentMismatches: 0, + lastActivityByBia: [], + }, + voucherSummary: { + trackedVoucherTokens: 0, + walletsWithVoucherBalances: 0, + merchantCreditRows: 0, + lastVoucherBlock: null, + }, + torontoCoinTracking: { + requiredTokenAddress: "0xAEC330E9d808E4e938bf830016c6B2Eb350e1A19", + cplTcoinTracked: true, + trackedPools: [], + }, + activePoolDetails: [ + { + poolAddress: "0xde2a979ec49811ad27730e451651e52b4540c594", + tokenAddresses: ["0xaec330e9d808e4e938bf830016c6b2eb350e1a19"], + }, + { + poolAddress: "0xa6f024ad53766d332057d5e40215b695522ee3de", + tokenAddresses: ["0xaec330e9d808e4e938bf830016c6b2eb350e1a19"], + }, + ], + }, + error: null, + }); + + const status = await getIndexerScopeStatusReadModel({ + supabase: { rpc } as never, + expectedTorontoCoinPools: [ + { + poolId: + "0x746f726f6e746f2d63697263756c61722d65636f6e6f6d790000000000000000", + poolAddress: "0xA6f024Ad53766d332057d5e40215b695522ee3dE", + expectedIndexerVisibility: true, + }, + ], + }); + + expect(status.torontoCoinTracking?.trackedPools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + poolId: + "0x746f726f6e746f2d63697263756c61722d65636f6e6f6d790000000000000000", + tracked: true, + healthy: true, + }), + ]) + ); + }); + it("defaults omitted scope parameters from the configured runtime environment", async () => { process.env.NEXT_PUBLIC_CITYCOIN = "othercoin"; process.env.INDEXER_CHAIN_ID = "12345"; diff --git a/shared/lib/indexer/statusReadModel.ts b/shared/lib/indexer/statusReadModel.ts index 17ab3cb..815ee4d 100644 --- a/shared/lib/indexer/statusReadModel.ts +++ b/shared/lib/indexer/statusReadModel.ts @@ -20,6 +20,11 @@ type IndexerScopeStatusReadOptions = { citySlug?: string | null; chainId?: number | null; requiredTokenAddress?: string | null; + expectedTorontoCoinPools?: Array<{ + poolId: string; + poolAddress: string; + expectedIndexerVisibility: boolean; + }>; }; function normaliseCitySlug(value?: string | null) { @@ -51,13 +56,25 @@ function stripInternalPoolDetails(status: RpcIndexerScopeStatus): IndexerScopeSt return safeStatus; } -function enrichTorontoCoinTracking(status: RpcIndexerScopeStatus): IndexerScopeStatus { +function enrichTorontoCoinTracking( + status: RpcIndexerScopeStatus, + expectedTorontoCoinPools: NonNullable = [] +): IndexerScopeStatus { const runtimePools = getConfiguredTorontoCoinTrackedPools({ citySlug: status.citySlug, chainId: status.chainId, }); - - if (runtimePools.length === 0) { + const runtimePoolKeys = new Set( + runtimePools.map((pool) => `${pool.poolId.toLowerCase()}:${normaliseAddress(pool.poolAddress)}`) + ); + const trackedPoolInputs = [ + ...runtimePools, + ...expectedTorontoCoinPools.filter( + (pool) => !runtimePoolKeys.has(`${pool.poolId.toLowerCase()}:${normaliseAddress(pool.poolAddress)}`) + ), + ]; + + if (trackedPoolInputs.length === 0) { return stripInternalPoolDetails(status); } @@ -66,7 +83,7 @@ function enrichTorontoCoinTracking(status: RpcIndexerScopeStatus): IndexerScopeS .filter((detail) => Boolean(detail.poolAddress)) .map((detail) => [normaliseAddress(detail.poolAddress), detail]) ); - const trackedPools = runtimePools.map((pool) => { + const trackedPools = trackedPoolInputs.map((pool) => { const poolDetail = activePoolsByAddress.get(normaliseAddress(pool.poolAddress)); const tokenAddresses = poolDetail?.tokenAddresses ?? []; const tracked = Boolean(poolDetail); @@ -111,5 +128,5 @@ export async function getIndexerScopeStatusReadModel( throw new Error(`Failed to load indexer scope status: ${error.message}`); } - return enrichTorontoCoinTracking(data as RpcIndexerScopeStatus); + return enrichTorontoCoinTracking(data as RpcIndexerScopeStatus, options.expectedTorontoCoinPools); } From 7f9197859bb039bb35619b55293b1d2b951e455f Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 09:02:53 -0400 Subject: [PATCH 09/21] fix: wire live pools into ops status API --- agent-context/session-log/dev.md | 9 +++++++++ app/api/tcoin/ops/status/route.test.ts | 15 ++++++++++++++- app/api/tcoin/ops/status/route.ts | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index bafe903..648ac93 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T13:03:12Z - Address PR #94 ops status review + +- agent: Codex +- branch: codex/tcoin-production-launch-readiness +- head: 41b65e7 +- summary: Addressed the Codex review comment on PR #94 by wiring live TorontoCoin pools into `/api/tcoin/ops/status` indexer enrichment, matching the CLI ops checks so hosted admin/operator status no longer falls back to bootstrap-only tracked pool metadata. +- validation: `pnpm exec vitest run app/api/tcoin/ops/status/route.test.ts shared/lib/indexer/statusReadModel.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`. +- follow-ups: Push the review-fix commit to PR #94, reply to the review thread with the fix commit, resolve the thread, and wait for CI to rerun. + ## 2026-07-20T00:55:12Z - Repair Preview Supabase release alignment - agent: Codex diff --git a/app/api/tcoin/ops/status/route.test.ts b/app/api/tcoin/ops/status/route.test.ts index 68a695f..da386a4 100644 --- a/app/api/tcoin/ops/status/route.test.ts +++ b/app/api/tcoin/ops/status/route.test.ts @@ -40,7 +40,13 @@ describe("GET /api/tcoin/ops/status", () => { h.mockGetTorontoCoinOpsStatus.mockResolvedValue({ addresses: { liquidityRouter: "0xrouter", chainId: 42220, cplTcoin: "0xtoken" }, ownership: {}, - pools: [], + pools: [ + { + poolId: "0xpoolid", + poolAddress: "0xpool", + expectedIndexerVisibility: true, + }, + ], reserveRouteHealth: {}, artifactTimestamps: {}, }); @@ -70,6 +76,13 @@ describe("GET /api/tcoin/ops/status", () => { citySlug: "tcoin", chainId: 42220, requiredTokenAddress: "0xtoken", + expectedTorontoCoinPools: [ + { + poolId: "0xpoolid", + poolAddress: "0xpool", + expectedIndexerVisibility: true, + }, + ], }) ); }); diff --git a/app/api/tcoin/ops/status/route.ts b/app/api/tcoin/ops/status/route.ts index 13561b0..063aa25 100644 --- a/app/api/tcoin/ops/status/route.ts +++ b/app/api/tcoin/ops/status/route.ts @@ -22,6 +22,7 @@ export async function GET() { citySlug: "tcoin", chainId: status.addresses.chainId, requiredTokenAddress: status.addresses.cplTcoin, + expectedTorontoCoinPools: status.pools, }); return NextResponse.json({ From 332446bdead8e257006e66f2946c2ba191b5c111 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 10:34:30 -0400 Subject: [PATCH 10/21] chore: configure tcoin worker scheduler target (#88) --- agent-context/session-log/dev.md | 9 ++++++++ docs/engineering/wallet-release-runbook.md | 25 ++++++++++++++++++++++ package.json | 2 ++ scripts/indexer-touch-worker.ts | 4 +++- services/indexer/src/touchQueue.test.ts | 17 +++++++++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 648ac93..630b486 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T14:35:24Z - Configure bounded worker scheduler target + +- agent: Codex +- branch: codex/tcoin-worker-scheduler-proof +- head: 878c6e8 +- summary: Started Sprint #78 Goal #80 Task #88 and configured the indexer scheduler target contract without linked Supabase writes. The indexer touch worker now accepts `INDEXER_TOUCH_WORKER_SCOPE_KEY`, package scripts expose local/remote profile-backed drain commands, and the wallet release runbook documents the one-minute one-shot scheduler cadence, required worker env, service-role boundary, and current blocker: recurring scheduler enablement must wait for Task #89 queue-drain proof because prior Preview worker attempts timed out before checkpoint progress. +- validation: `pnpm exec vitest run services/indexer/src/touchQueue.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`. +- follow-ups: Task #89 must prove bounded queue drain and health reporting before any linked Preview/Production recurring scheduler is enabled. Linked Supabase scheduler writes were not run in this task because no just-in-time approval was provided for this turn. + ## 2026-07-20T13:03:12Z - Address PR #94 ops status review - agent: Codex diff --git a/docs/engineering/wallet-release-runbook.md b/docs/engineering/wallet-release-runbook.md index 0fa8975..3b54126 100644 --- a/docs/engineering/wallet-release-runbook.md +++ b/docs/engineering/wallet-release-runbook.md @@ -103,6 +103,31 @@ Operational note: - If queue-backed indexer health reports `blocked` or `stale`, treat that as a release blocker until the worker or scheduler is running again and the pending queue drains. - Treat any reported `releaseBlockers` output as a hard stop for go-live. +### Async indexer worker scheduler target + +The scheduler target for queued indexer touches is the external Node worker command, not the deployed Next.js runtime: + +```bash +INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 pnpm ops:indexer:drain-touch-queue +``` + +For local operator proof against the ignored env profiles, use: + +```bash +INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 pnpm ops:indexer:drain-touch-queue:supabase-local +INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 pnpm ops:indexer:drain-touch-queue:supabase-remote +``` + +Recommended scheduler contract: + +- cadence: every minute +- mode: one-shot command execution only; do not add a long-running loop in this pass +- scope: `INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220` +- runtime env: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_CITYCOIN=tcoin`, `INDEXER_CHAIN_ID=42220`, `INDEXER_CHAIN_RPC_URL`, `INDEXER_INITIAL_BLOCK`, `INDEXER_MAX_BLOCKS_PER_RUN`, and `INDEXER_DISCOVERY_POOL_LIMIT` +- service-role boundary: the service-role key belongs only in this worker runtime and privileged Edge Functions, not in the deployed Next.js shell + +Do not enable a linked Preview or Production recurring scheduler until queue drain proof is captured. During the Preview repair on 2026-07-20, manual one-shot queue-drain attempts timed out before checkpoint progress, so Task #89 must first prove bounded queue drain behaviour and health reporting. If an operator configures a hosted scheduler, record the exact target, cadence, environment, and latest run evidence on Goal #80. + ### CI-assisted remote alignment `.github/workflows/release-alignment-tcoin.yml` automates the remote/deployment release-alignment checks after the Supabase migration deploy workflow succeeds on `dev` or `main`. It is also available through manual dispatch for Preview or Production. diff --git a/package.json b/package.json index 4a886aa..bf879b9 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "ops:wallet:preflight:supabase-remote": "tsx scripts/wallet-release-preflight.ts --profile=supabase-remote", "ops:wallet:preflight:deployment": "tsx scripts/wallet-release-preflight.ts --profile=deployment", "ops:indexer:drain-touch-queue": "tsx scripts/indexer-touch-worker.ts", + "ops:indexer:drain-touch-queue:supabase-local": "tsx scripts/run-with-env-profile.ts .env.local-supabase-local -- pnpm ops:indexer:drain-touch-queue", + "ops:indexer:drain-touch-queue:supabase-remote": "tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:indexer:drain-touch-queue", "ops:torontocoin": "tsx scripts/torontocoin-ops-check.ts", "ops:torontocoin:pools": "tsx scripts/torontocoin-pool-compatibility-check.ts", "ops:torontocoin:acceptance": "tsx scripts/torontocoin-pool-acceptance.ts", diff --git a/scripts/indexer-touch-worker.ts b/scripts/indexer-touch-worker.ts index 3fc4eff..7413aa8 100644 --- a/scripts/indexer-touch-worker.ts +++ b/scripts/indexer-touch-worker.ts @@ -8,12 +8,14 @@ async function main() { const supabase = createServiceRoleClientCore({ context: "indexer touch worker", }); - const result = await drainIndexerTouchQueueOnce({ supabase }); + const scopeKey = process.env.INDEXER_TOUCH_WORKER_SCOPE_KEY?.trim() || null; + const result = await drainIndexerTouchQueueOnce({ supabase, scopeKey }); console.log( JSON.stringify( { generatedAt: new Date().toISOString(), + scopeKey, processed: result.processed, request: result.request, result: result.result, diff --git a/services/indexer/src/touchQueue.test.ts b/services/indexer/src/touchQueue.test.ts index 026f13f..2f919f3 100644 --- a/services/indexer/src/touchQueue.test.ts +++ b/services/indexer/src/touchQueue.test.ts @@ -41,6 +41,23 @@ describe("drainIndexerTouchQueueOnce", () => { ).rejects.toThrow("A scoped service-role Supabase client is required to drain indexer touch requests."); }); + it("can claim only one configured scheduler scope", async () => { + const rpc = vi.fn().mockResolvedValueOnce({ + data: null, + error: null, + }); + const schema = vi.fn().mockReturnValue({ rpc }); + + await drainIndexerTouchQueueOnce({ + supabase: { schema, rpc } as never, + scopeKey: "tcoin:42220", + }); + + expect(rpc).toHaveBeenCalledWith("claim_touch_request_v1", { + p_scope_key: "tcoin:42220", + }); + }); + it("claims a queued request, runs the indexer, and marks success", async () => { const rpc = vi .fn() From 304d77368362eea1e37c3b43f00d785726f20251 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 10:38:35 -0400 Subject: [PATCH 11/21] fix: bound indexer queue drain worker (#89) --- agent-context/session-log/dev.md | 9 ++++ docs/engineering/wallet-release-runbook.md | 9 ++-- scripts/indexer-touch-worker.ts | 22 ++++++++- services/indexer/src/touchQueue.test.ts | 49 ++++++++++++++++++++ services/indexer/src/touchQueue.ts | 52 ++++++++++++++++++++-- 5 files changed, 132 insertions(+), 9 deletions(-) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 630b486..0f34abf 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -9,6 +9,15 @@ - validation: `pnpm exec vitest run services/indexer/src/touchQueue.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`. - follow-ups: Task #89 must prove bounded queue drain and health reporting before any linked Preview/Production recurring scheduler is enabled. Linked Supabase scheduler writes were not run in this task because no just-in-time approval was provided for this turn. +## 2026-07-20T14:39:46Z - Prove bounded indexer queue drain behaviour + +- agent: Codex +- branch: codex/tcoin-worker-scheduler-proof +- head: 332446b +- summary: Continued Sprint #78 Goal #80 Task #89 by adding a bounded worker timeout path for indexer queue drains. Timed-out workers now mark both `indexer.run_control` and the claimed touch request as failed before the CLI exits non-zero, preventing the previous indefinite running-state failure mode. Read-only remote health confirmed Preview currently has no blockers, queue pending count `0`, and queue `blocked=false`/`stale=false`. +- validation: `pnpm exec vitest run services/indexer/src/touchQueue.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`; `pnpm ops:wallet:preflight:supabase-remote`; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin`. +- follow-ups: A real linked Preview queue-drain run still requires just-in-time approval to create or identify a safe queue request. Until that approval is given and the drain is observed, recurring scheduler enablement should remain blocked. + ## 2026-07-20T13:03:12Z - Address PR #94 ops status review - agent: Codex diff --git a/docs/engineering/wallet-release-runbook.md b/docs/engineering/wallet-release-runbook.md index 3b54126..8535422 100644 --- a/docs/engineering/wallet-release-runbook.md +++ b/docs/engineering/wallet-release-runbook.md @@ -108,14 +108,14 @@ Operational note: The scheduler target for queued indexer touches is the external Node worker command, not the deployed Next.js runtime: ```bash -INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 pnpm ops:indexer:drain-touch-queue +INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 INDEXER_TOUCH_WORKER_TIMEOUT_MS=240000 pnpm ops:indexer:drain-touch-queue ``` For local operator proof against the ignored env profiles, use: ```bash -INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 pnpm ops:indexer:drain-touch-queue:supabase-local -INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 pnpm ops:indexer:drain-touch-queue:supabase-remote +INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 INDEXER_TOUCH_WORKER_TIMEOUT_MS=240000 pnpm ops:indexer:drain-touch-queue:supabase-local +INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 INDEXER_TOUCH_WORKER_TIMEOUT_MS=240000 pnpm ops:indexer:drain-touch-queue:supabase-remote ``` Recommended scheduler contract: @@ -123,9 +123,12 @@ Recommended scheduler contract: - cadence: every minute - mode: one-shot command execution only; do not add a long-running loop in this pass - scope: `INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220` +- timeout: `INDEXER_TOUCH_WORKER_TIMEOUT_MS=240000` unless a lower target-specific bound has been proven safe - runtime env: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_CITYCOIN=tcoin`, `INDEXER_CHAIN_ID=42220`, `INDEXER_CHAIN_RPC_URL`, `INDEXER_INITIAL_BLOCK`, `INDEXER_MAX_BLOCKS_PER_RUN`, and `INDEXER_DISCOVERY_POOL_LIMIT` - service-role boundary: the service-role key belongs only in this worker runtime and privileged Edge Functions, not in the deployed Next.js shell +If the timeout fires, the worker marks both `indexer.run_control` and the claimed touch request as `error`/`failed` before exiting non-zero. Treat repeated timeout failures as a release blocker rather than increasing the timeout without first checking RPC latency, `INDEXER_MAX_BLOCKS_PER_RUN`, and `INDEXER_DISCOVERY_POOL_LIMIT`. + Do not enable a linked Preview or Production recurring scheduler until queue drain proof is captured. During the Preview repair on 2026-07-20, manual one-shot queue-drain attempts timed out before checkpoint progress, so Task #89 must first prove bounded queue drain behaviour and health reporting. If an operator configures a hosted scheduler, record the exact target, cadence, environment, and latest run evidence on Goal #80. ### CI-assisted remote alignment diff --git a/scripts/indexer-touch-worker.ts b/scripts/indexer-touch-worker.ts index 7413aa8..3ba732e 100644 --- a/scripts/indexer-touch-worker.ts +++ b/scripts/indexer-touch-worker.ts @@ -4,18 +4,36 @@ import { drainIndexerTouchQueueOnce } from "../services/indexer/src/touchQueue.t loadRepoEnv(); +const DEFAULT_WORKER_TIMEOUT_MS = 240_000; + +function resolveWorkerTimeoutMs() { + const configured = process.env.INDEXER_TOUCH_WORKER_TIMEOUT_MS?.trim(); + if (!configured) { + return DEFAULT_WORKER_TIMEOUT_MS; + } + + const parsed = Number(configured); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error("INDEXER_TOUCH_WORKER_TIMEOUT_MS must be a positive number of milliseconds."); + } + + return parsed; +} + async function main() { const supabase = createServiceRoleClientCore({ context: "indexer touch worker", }); const scopeKey = process.env.INDEXER_TOUCH_WORKER_SCOPE_KEY?.trim() || null; - const result = await drainIndexerTouchQueueOnce({ supabase, scopeKey }); + const timeoutMs = resolveWorkerTimeoutMs(); + const result = await drainIndexerTouchQueueOnce({ supabase, scopeKey, timeoutMs }); console.log( JSON.stringify( { generatedAt: new Date().toISOString(), scopeKey, + timeoutMs, processed: result.processed, request: result.request, result: result.result, @@ -28,5 +46,5 @@ async function main() { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + process.exit(1); }); diff --git a/services/indexer/src/touchQueue.test.ts b/services/indexer/src/touchQueue.test.ts index 2f919f3..58482f9 100644 --- a/services/indexer/src/touchQueue.test.ts +++ b/services/indexer/src/touchQueue.test.ts @@ -150,4 +150,53 @@ describe("drainIndexerTouchQueueOnce", () => { p_error: "boom", }); }); + + it("marks run control and queue request failed when the worker times out", async () => { + const schemaRpc = vi + .fn() + .mockResolvedValueOnce({ + data: { + requestId: 11, + scopeKey: "tcoin:42220", + citySlug: "tcoin", + chainId: 42220, + source: "scheduler", + requestedAt: "2026-04-26T12:00:00.000Z", + claimedAt: "2026-04-26T12:00:01.000Z", + attemptCount: 1, + }, + error: null, + }) + .mockResolvedValueOnce({ + data: null, + error: null, + }); + const rootRpc = vi.fn().mockResolvedValue({ + data: null, + error: null, + }); + const schema = vi.fn().mockReturnValue({ rpc: schemaRpc }); + + h.mockRunIndexerTouch.mockReturnValue(new Promise(() => {})); + + await expect( + drainIndexerTouchQueueOnce({ + supabase: { schema, rpc: rootRpc } as never, + timeoutMs: 1, + }) + ).rejects.toThrow("Indexer touch worker timed out after 1ms."); + + expect(rootRpc).toHaveBeenCalledWith("indexer_complete_run", { + p_scope_key: "tcoin:42220", + p_status: "error", + p_error: "Indexer touch worker timed out after 1ms.", + p_cooldown_seconds: 300, + }); + expect(schemaRpc).toHaveBeenNthCalledWith(2, "complete_touch_request_v1", { + p_request_id: 11, + p_status: "failed", + p_run_status: "error", + p_error: "Indexer touch worker timed out after 1ms.", + }); + }); }); diff --git a/services/indexer/src/touchQueue.ts b/services/indexer/src/touchQueue.ts index e239ba9..911afa9 100644 --- a/services/indexer/src/touchQueue.ts +++ b/services/indexer/src/touchQueue.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { runIndexerTouch } from "./index"; +import { completeRun } from "./state/runControl"; import type { IndexerTouchResult } from "./types"; export type ClaimedIndexerTouchRequest = { @@ -25,6 +26,37 @@ export type DrainIndexerTouchQueueResult = result: IndexerTouchResult; }; +class IndexerTouchWorkerTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Indexer touch worker timed out after ${timeoutMs}ms.`); + this.name = "IndexerTouchWorkerTimeoutError"; + } +} + +function isPositiveTimeout(timeoutMs?: number | null): timeoutMs is number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0; +} + +async function withWorkerTimeout(promise: Promise, timeoutMs?: number | null): Promise { + if (!isPositiveTimeout(timeoutMs)) { + return promise; + } + + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new IndexerTouchWorkerTimeoutError(timeoutMs)), timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + function parseClaimedRequest(data: unknown): ClaimedIndexerTouchRequest | null { if (!data || typeof data !== "object") { return null; @@ -98,6 +130,7 @@ export async function completeIndexerTouchRequest(options: { export async function drainIndexerTouchQueueOnce(options: { supabase: SupabaseClient; scopeKey?: string | null; + timeoutMs?: number | null; }): Promise { const supabase = options?.supabase; if (!supabase) { @@ -118,10 +151,13 @@ export async function drainIndexerTouchQueueOnce(options: { } try { - const result = await runIndexerTouch({ - supabase, - citySlug: request.citySlug, - }); + const result = await withWorkerTimeout( + runIndexerTouch({ + supabase, + citySlug: request.citySlug, + }), + options.timeoutMs + ); await completeIndexerTouchRequest({ supabase, @@ -143,6 +179,14 @@ export async function drainIndexerTouchQueueOnce(options: { }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown indexer queue error"; + if (error instanceof IndexerTouchWorkerTimeoutError) { + await completeRun({ + supabase, + scopeKey: request.scopeKey, + status: "error", + errorMessage: message, + }); + } await completeIndexerTouchRequest({ supabase, requestId: request.requestId, From 8cb1be634b13871746fc9c44372517f45bd565a0 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 10:41:28 -0400 Subject: [PATCH 12/21] docs: capture scheduler runbook evidence (#90) --- agent-context/session-log/dev.md | 9 ++++++++ docs/engineering/wallet-release-runbook.md | 24 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 0f34abf..74e7e1f 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -18,6 +18,15 @@ - validation: `pnpm exec vitest run services/indexer/src/touchQueue.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`; `pnpm ops:wallet:preflight:supabase-remote`; `pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin`. - follow-ups: A real linked Preview queue-drain run still requires just-in-time approval to create or identify a safe queue request. Until that approval is given and the drain is observed, recurring scheduler enablement should remain blocked. +## 2026-07-20T14:41:15Z - Capture scheduler runbook evidence + +- agent: Codex +- branch: codex/tcoin-worker-scheduler-proof +- head: 304d773 +- summary: Completed Sprint #78 Goal #80 Task #90 by expanding the wallet release runbook with scheduler verification evidence requirements, concrete health fields to inspect, manual retry guidance, repeated-timeout handling, and escalation rules that keep the service-role key out of the deployed Next.js shell. +- validation: `pnpm exec vitest run services/indexer/src/touchQueue.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`. +- follow-ups: Goal #80 can move to review with a residual launch blocker: actual hosted Preview queue-drain proof and recurring scheduler enablement still require just-in-time linked Supabase/operator approval. + ## 2026-07-20T13:03:12Z - Address PR #94 ops status review - agent: Codex diff --git a/docs/engineering/wallet-release-runbook.md b/docs/engineering/wallet-release-runbook.md index 8535422..b2ffc40 100644 --- a/docs/engineering/wallet-release-runbook.md +++ b/docs/engineering/wallet-release-runbook.md @@ -131,6 +131,30 @@ If the timeout fires, the worker marks both `indexer.run_control` and the claime Do not enable a linked Preview or Production recurring scheduler until queue drain proof is captured. During the Preview repair on 2026-07-20, manual one-shot queue-drain attempts timed out before checkpoint progress, so Task #89 must first prove bounded queue drain behaviour and health reporting. If an operator configures a hosted scheduler, record the exact target, cadence, environment, and latest run evidence on Goal #80. +Operator verification after scheduler configuration: + +1. Queue or identify one safe Preview request for the `tcoin:42220` scope. +2. Run the scheduler target once manually and confirm the command exits `0` for success/skip or non-zero for bounded failure. +3. Re-run: + ```bash + pnpm ops:wallet:preflight:supabase-remote + pnpm exec tsx scripts/run-with-env-profile.ts .env.local-supabase-remote -- pnpm ops:torontocoin + ``` +4. Confirm the health output reports: + - `indexerSummary.queue.pendingRequestCount = 0` + - `indexerSummary.queue.blocked = false` + - `indexerSummary.queue.stale = false` + - `indexerSummary.lastStatus = success|skipped` for a healthy drain, or `error` with a bounded failure reason for a failed drain + - `releaseBlockers = []` in the TorontoCoin ops output +5. Capture the scheduler provider, target command, cadence, latest run timestamp, exit status, and relevant preflight/ops excerpts on Goal #80. + +Failure handling: + +- If the worker times out once, leave the scheduler disabled or paused, capture the failed request id and run-control status, and retry manually with lower `INDEXER_MAX_BLOCKS_PER_RUN` or `INDEXER_DISCOVERY_POOL_LIMIT` only if the target RPC and Supabase health are otherwise normal. +- If the worker times out repeatedly, treat it as a launch blocker and inspect pool discovery, RPC latency, and voucher/BIA derivation before enabling a recurring scheduler. +- If queue health is `blocked` or `stale`, do not proceed to Preview smoke. Drain or fail the stuck request through the worker boundary first, then re-run preflight. +- If the scheduler provider cannot run the Node command with the service-role worker env, do not move `SUPABASE_SERVICE_ROLE_KEY` into Vercel/Next.js. Use a dedicated worker runtime or Supabase Edge/worker implementation instead. + ### CI-assisted remote alignment `.github/workflows/release-alignment-tcoin.yml` automates the remote/deployment release-alignment checks after the Supabase migration deploy workflow succeeds on `dev` or `main`. It is also available through manual dispatch for Preview or Production. From e225080b1754e9a8e9472a52e60291d90deb6b92 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 10:50:34 -0400 Subject: [PATCH 13/21] docs: document worker scheduler env (#90) --- .env.example | 12 ++++++++++++ agent-context/session-log/dev.md | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/.env.example b/.env.example index 8e3958f..c3de7fc 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,18 @@ INDEXER_MAX_BLOCKS_PER_RUN=2000 # Example: 500 INDEXER_DISCOVERY_POOL_LIMIT=500 +# Optional queue scope for scheduled indexer touch workers. +# Use `tcoin:42220` for the TorontoCoin launch worker so the scheduler does +# not claim unrelated queued scopes. +# Example: tcoin:42220 +INDEXER_TOUCH_WORKER_SCOPE_KEY=tcoin:42220 + +# Maximum runtime for one scheduled indexer touch worker process in +# milliseconds. Timed-out workers mark the queue request and run-control health +# as failed before exiting non-zero. +# Example: 240000 +INDEXER_TOUCH_WORKER_TIMEOUT_MS=240000 + # Optional tracker bridge endpoint for tracker-pull ingestion. # Leave empty unless that bridge is intentionally deployed. # Example: http://localhost:8787/tracker/pull diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 74e7e1f..5a47f2f 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T14:51:02Z - Address PR #95 env template review + +- agent: Codex +- branch: codex/tcoin-worker-scheduler-proof +- head: 8cb1be6 +- summary: Addressed the Codex review comment on PR #95 by documenting `INDEXER_TOUCH_WORKER_SCOPE_KEY` and `INDEXER_TOUCH_WORKER_TIMEOUT_MS` in `.env.example` with safe TorontoCoin launch defaults and timeout behaviour notes. +- validation: `pnpm exec vitest run services/indexer/src/touchQueue.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`. +- follow-ups: Push the fix to PR #95, reply to and resolve the Codex review thread, then wait for CI to rerun. + ## 2026-07-20T14:35:24Z - Configure bounded worker scheduler target - agent: Codex From da5fb273dfb6cea4d9172362f51800993f058179 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 13:42:24 -0400 Subject: [PATCH 14/21] docs: record preview buy tcoin blocker (#85) --- agent-context/session-log/dev.md | 9 +++++++++ docs/engineering/wallet-release-runbook.md | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 5a47f2f..279f65d 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T17:45:30Z - Record Preview Buy TCOIN sandbox blocker + +- agent: Codex +- branch: codex/tcoin-preview-smoke-buy +- head: 578c5eb +- summary: Started Sprint #78 Goal #79 Task #85 and attempted hosted Preview smoke for the Buy TCOIN sandbox path. The configured Preview smoke URL redirects all tested routes through Vercel Deployment Protection (`302` to `vercel.com/sso-api`), and the `Preview - tcoin` GitHub Environment currently has `NEXT_PUBLIC_ENABLE_BUY_TCOIN_CHECKOUT=false`, so Buy TCOIN sandbox checkout is not currently smokeable from this shell. Added runbook prerequisites for Preview Buy TCOIN sandbox evidence and kept live/Production enablement gated. +- validation: `gh variable list --env "Preview – tcoin"`; `gh secret list --env "Preview – tcoin"`; `SMOKE_BASE_URL=https://tcoin-git-dev-cubid-team.vercel.app SMOKE_SKIP_BUILD=1 pnpm smoke:e2e` failed because every route redirected to Vercel `/login`; `curl -sS -D - -o /tmp/tcoin-preview-wallet.html -L https://tcoin-git-dev-cubid-team.vercel.app/tcoin/wallet` confirmed Vercel SSO redirect headers. +- follow-ups: To complete Task #85, an operator must either disable Vercel Deployment Protection for the Preview smoke target or provide an approved automation bypass, then configure Vercel Preview plus Preview Edge Function onramp sandbox env and redeploy with `NEXT_PUBLIC_ENABLE_BUY_TCOIN_CHECKOUT=true`. + ## 2026-07-20T14:51:02Z - Address PR #95 env template review - agent: Codex diff --git a/docs/engineering/wallet-release-runbook.md b/docs/engineering/wallet-release-runbook.md index b2ffc40..c09bc4c 100644 --- a/docs/engineering/wallet-release-runbook.md +++ b/docs/engineering/wallet-release-runbook.md @@ -232,6 +232,17 @@ Use a clean browser profile if possible. 8. If Twilio env is configured, open the wallet off-ramp flow and confirm `/api/send_otp` plus `/api/verify_otp` both succeed. 9. If `NEXT_PUBLIC_ENABLE_BUY_TCOIN_CHECKOUT=true`, open the Buy TCOIN modal and confirm it can create a checkout session without exposing the configuration-error fallback state. +### Preview Buy TCOIN sandbox prerequisites + +Do not treat Preview Buy TCOIN as smokeable until all of these are true: + +1. The Preview deployment URL is reachable without Vercel SSO or has an approved automation-bypass token available to the smoke runner. +2. Vercel Preview env has `NEXT_PUBLIC_ENABLE_BUY_TCOIN_CHECKOUT=true`. +3. The matching Preview Supabase Edge Function/runtime env has the sandbox `ONRAMP_*` provider secrets and deposit-wallet settings required by the onramp function. +4. The checkout provider is in sandbox/test mode, and the runbook evidence identifies the provider environment without recording secret values. + +If any prerequisite is missing, keep `NEXT_PUBLIC_ENABLE_BUY_TCOIN_CHECKOUT=false`, record the exact blocker on the Preview smoke Goal, and do not attempt to enable the live Production path. + ## Production smoke steps Run these immediately after deploy against the production host. From 4f920395a1a6ac30903f56b062869a0d8a3f97a9 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 13:49:16 -0400 Subject: [PATCH 15/21] fix: pass verified auth token to user provisioning (#86) --- agent-context/session-log/dev.md | 9 ++++++ .../components/modals/SignInModal.tsx | 12 ++++--- .../components/modals/SignInModal.test.tsx | 2 +- .../wallet/components/modals/SignInModal.tsx | 2 +- shared/api/hooks/useAuth.ts | 3 +- shared/api/services/supabaseService.ts | 17 ++++++++-- shared/lib/edge/core.test.ts | 31 +++++++++++++++++++ shared/lib/edge/core.ts | 4 ++- shared/lib/edge/userSettingsClient.ts | 4 ++- 9 files changed, 72 insertions(+), 12 deletions(-) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index 279f65d..dcebcdb 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T17:55:44Z - Fix Preview OTP provisioning token handoff + +- agent: Codex +- branch: codex/tcoin-preview-smoke-buy +- head: da5fb27 +- summary: Continued Sprint #78 Goal #79 Task #86 by tracing Preview OTP smoke in Chrome. OTP delivery succeeded and the Preview auth modal accepted the code, but hosted provisioning then looped on `Unauthorized` while calling the authenticated user-settings ensure-user path. Patched the internal Edge client to accept a freshly verified access-token override and wired wallet/sparechange sign-in plus `useAuth` user reconciliation through that token so post-OTP provisioning does not depend on a delayed session re-read. +- validation: `pnpm exec vitest run shared/lib/edge/core.test.ts app/tcoin/wallet/components/modals/SignInModal.test.tsx`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`. +- follow-ups: Publish this branch and rerun hosted Preview OTP smoke against the PR deployment before claiming Task #86 fully green. Do not proceed to Task #87 pay-link create/resolve until signed-in Preview dashboard smoke passes on a deployed build. + ## 2026-07-20T17:45:30Z - Record Preview Buy TCOIN sandbox blocker - agent: Codex diff --git a/app/tcoin/sparechange/components/modals/SignInModal.tsx b/app/tcoin/sparechange/components/modals/SignInModal.tsx index 3ae3226..c4b7efa 100644 --- a/app/tcoin/sparechange/components/modals/SignInModal.tsx +++ b/app/tcoin/sparechange/components/modals/SignInModal.tsx @@ -4,6 +4,7 @@ import OTPForm from "@tcoin/sparechange/components/forms/OTPForm"; import { useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "react-toastify"; +import type { Session } from "@supabase/supabase-js"; import { fetchUserByContact, waitForAuthenticatedSession } from "@shared/api/services/supabaseService"; @@ -67,9 +68,9 @@ function SignInModal({ closeModal }: SignInModalProps) { }); const verifyCodeMut = useVerifyPasscodeMutation({ - onSuccessCallback: async (result) => { + onSuccessCallback: async (verifiedSession?: Session | null) => { toast.success("Passcode verified successfully!"); - await handlePostAuthentication(fullContact); + await handlePostAuthentication(fullContact, verifiedSession ?? null); closeModal(); }, onErrorCallback: (err) => { @@ -95,14 +96,15 @@ function SignInModal({ closeModal }: SignInModalProps) { [authMethod, fullContact, passcode, verifyCodeMut] ); - const handlePostAuthentication = async (fullContact: string) => { - const session = await waitForAuthenticatedSession(); + const handlePostAuthentication = async (fullContact: string, verifiedSession?: Session | null) => { + const session = + verifiedSession?.access_token ? verifiedSession : await waitForAuthenticatedSession(); if (!session?.access_token) { toast.error("We couldn't finish signing you in. Please try again."); return; } - const { user, error } = await fetchUserByContact(authMethod, fullContact); + const { user, error } = await fetchUserByContact(authMethod, fullContact, session.access_token); if (error || !user) { console.error("Failed to finish authenticated user provisioning:", error); diff --git a/app/tcoin/wallet/components/modals/SignInModal.test.tsx b/app/tcoin/wallet/components/modals/SignInModal.test.tsx index c32149e..17e6cd1 100644 --- a/app/tcoin/wallet/components/modals/SignInModal.test.tsx +++ b/app/tcoin/wallet/components/modals/SignInModal.test.tsx @@ -152,7 +152,7 @@ describe("SignInModal", () => { }); expect(waitForAuthenticatedSessionMock).not.toHaveBeenCalled(); - expect(fetchUserByContactMock).toHaveBeenCalled(); + expect(fetchUserByContactMock).toHaveBeenCalledWith("email", "", "fresh-token"); expect(push).toHaveBeenCalledWith("/dashboard"); expect(closeModal).toHaveBeenCalled(); cleanup(); diff --git a/app/tcoin/wallet/components/modals/SignInModal.tsx b/app/tcoin/wallet/components/modals/SignInModal.tsx index 68efb79..1cff33b 100644 --- a/app/tcoin/wallet/components/modals/SignInModal.tsx +++ b/app/tcoin/wallet/components/modals/SignInModal.tsx @@ -116,7 +116,7 @@ function SignInModal({ closeModal, postAuthRedirect }: SignInModalProps) { return null; } - const { user, error } = await fetchUserByContact(authMethod, fullContact); + const { user, error } = await fetchUserByContact(authMethod, fullContact, session.access_token); if (error || !user) { console.error("Failed to finish authenticated user provisioning:", error); diff --git a/shared/api/hooks/useAuth.ts b/shared/api/hooks/useAuth.ts index e8d48af..49be951 100644 --- a/shared/api/hooks/useAuth.ts +++ b/shared/api/hooks/useAuth.ts @@ -118,7 +118,8 @@ export const useAuth = () => { try { const { user, error: ensuredUserError } = await fetchUserByContact( authQuery?.data?.user?.app_metadata?.provider || "email", - authQuery?.data?.user?.email || "" + authQuery?.data?.user?.email || "", + accessToken ); if (ensuredUserError) { diff --git a/shared/api/services/supabaseService.ts b/shared/api/services/supabaseService.ts index 49be1c3..e0c385c 100644 --- a/shared/api/services/supabaseService.ts +++ b/shared/api/services/supabaseService.ts @@ -112,11 +112,17 @@ export const normaliseDeviceInfo = (value: DeviceInfoPayload | null | undefined) return Object.keys(normalised).length > 0 ? normalised : null; }; -export const fetchUserByContact = async (authMethod: "phone" | "email" | string, fullContact: string) => { +export const fetchUserByContact = async ( + authMethod: "phone" | "email" | string, + fullContact: string, + accessToken?: string | null +) => { try { const result = await ensureAuthenticatedUserRecord({ authMethod, fullContact, + }, null, { + accessToken, }); return { @@ -181,12 +187,19 @@ export const fetchContactsForOwner = async (ownerUserId: number | string | null })); }; -export const createNewUser = async (authMethod: "phone" | "email", fullContact: string, uuid: string) => { +export const createNewUser = async ( + authMethod: "phone" | "email", + fullContact: string, + uuid: string, + accessToken?: string | null +) => { try { const result = await ensureAuthenticatedUserRecord({ authMethod, fullContact, cubidId: uuid, + }, null, { + accessToken, }); return { diff --git a/shared/lib/edge/core.test.ts b/shared/lib/edge/core.test.ts index 6fa754e..44f22c0 100644 --- a/shared/lib/edge/core.test.ts +++ b/shared/lib/edge/core.test.ts @@ -103,6 +103,37 @@ describe("invokeEdgeFunction", () => { ); }); + it("uses an explicit access token before falling back to session resolution", async () => { + const { setSessionSnapshot } = await import("@shared/lib/supabase/session"); + setSessionSnapshot(null); + getSessionMock.mockResolvedValue({ data: { session: null } }); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + const { invokeEdgeFunction } = await import("./core"); + + await invokeEdgeFunction("user-settings", "/auth/ensure-user", { + method: "POST", + body: { authMethod: "email", fullContact: "person@example.test" }, + appContext: { citySlug: "tcoin" }, + accessToken: "fresh-otp-token", + }); + + expect(getSessionMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer fresh-otp-token", + }), + }) + ); + }); + it("expands bare 404 not-found responses into a route-specific message", async () => { const { setSessionSnapshot } = await import("@shared/lib/supabase/session"); setSessionSnapshot(null); diff --git a/shared/lib/edge/core.ts b/shared/lib/edge/core.ts index a059fd6..d65f8a9 100644 --- a/shared/lib/edge/core.ts +++ b/shared/lib/edge/core.ts @@ -8,6 +8,7 @@ type EdgeInvokeOptions = { method?: "GET" | "POST" | "PATCH"; body?: Record; appContext?: AppScopeInput | null; + accessToken?: string | null; }; function normalizePath(path: string): string { @@ -41,7 +42,8 @@ export async function invokeEdgeFunction( const supabase = createClient(); const context = resolveAppScope(options?.appContext); const method = options?.method ?? "GET"; - const accessToken = await resolveAccessToken(supabase); + const explicitAccessToken = options?.accessToken?.trim(); + const accessToken = explicitAccessToken || (await resolveAccessToken(supabase)); const response = await fetch( `${resolveSupabaseUrl()}/functions/v1/${functionName}${normalizePath(path)}`, diff --git a/shared/lib/edge/userSettingsClient.ts b/shared/lib/edge/userSettingsClient.ts index ae60a7d..e7a507c 100644 --- a/shared/lib/edge/userSettingsClient.ts +++ b/shared/lib/edge/userSettingsClient.ts @@ -51,12 +51,14 @@ export type WalletCustodyMaterialResponse = { export async function ensureAuthenticatedUserRecord( payload?: EnsureUserRequest, - appContext?: AppScopeInput | null + appContext?: AppScopeInput | null, + options?: { accessToken?: string | null } ): Promise { return invokeEdgeFunction("user-settings", "/auth/ensure-user", { method: "POST", body: (payload ?? {}) as Record, appContext, + accessToken: options?.accessToken, }); } From c9d65300358588aee3416563ecd878544c7a53e6 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 13:50:19 -0400 Subject: [PATCH 16/21] chore: bump PR version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45b7211..3bb525b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genero", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genero", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@hookform/resolvers": "^4.1.2", "@radix-ui/react-accordion": "^1.2.3", diff --git a/package.json b/package.json index bf879b9..0d6cf1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genero", - "version": "0.1.0", + "version": "0.1.1", "private": true, "scripts": { "dev": "next dev", From 65d26afcaaf208726056957649f25912c02c3cb1 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 13:55:16 -0400 Subject: [PATCH 17/21] docs: record preview otp edge blocker (#86) --- agent-context/session-log/dev.md | 9 +++++++++ docs/engineering/wallet-release-runbook.md | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index dcebcdb..ca57dfe 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T17:59:38Z - Capture Preview Edge alignment blocker for OTP smoke + +- agent: Codex +- branch: codex/tcoin-preview-smoke-buy +- head: c9d6530 +- summary: Opened PR #96 and reran Task #86 against the PR Preview deployment at `https://tcoin-git-codex-tcoin-preview-smoke-buy-cubid-team.vercel.app`. Chrome reached the Preview dashboard, the auth modal sent a fresh OTP, Gmail delivered the Supabase Auth email, and the code was entered without recording it in durable comments/logs. The PR deployment still failed post-OTP provisioning with `Failed to finish authenticated user provisioning: Unauthorized`, so the remaining blocker is Preview Supabase Edge Function/runtime alignment for `user-settings /auth/ensure-user`, not the Next.js token handoff alone. +- validation: `pnpm test`; `pnpm build`; PR #96 checks observed `build-and-test=SUCCESS`, `trufflehog-diff=SUCCESS`, `version-bump=SUCCESS`, `Vercel - tcoin=SUCCESS`; Chrome hosted smoke against the PR Preview URL failed after OTP with the same `Unauthorized` provisioning error. +- follow-ups: Do not start Task #87 pay-link create/resolve until Task #86 is green. Completing #86 now requires explicit just-in-time approval to inspect/repair/deploy the Preview Supabase `user-settings` Edge Function/runtime env, including `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, against the intended Preview project. + ## 2026-07-20T17:55:44Z - Fix Preview OTP provisioning token handoff - agent: Codex diff --git a/docs/engineering/wallet-release-runbook.md b/docs/engineering/wallet-release-runbook.md index c09bc4c..60ae287 100644 --- a/docs/engineering/wallet-release-runbook.md +++ b/docs/engineering/wallet-release-runbook.md @@ -243,6 +243,17 @@ Do not treat Preview Buy TCOIN as smokeable until all of these are true: If any prerequisite is missing, keep `NEXT_PUBLIC_ENABLE_BUY_TCOIN_CHECKOUT=false`, record the exact blocker on the Preview smoke Goal, and do not attempt to enable the live Production path. +### Preview signed-in smoke prerequisites + +Preview signed-in smoke requires the deployed Next.js build and the Preview Supabase Edge Functions to be aligned. Before treating OTP smoke failures as app regressions, confirm: + +1. The Preview deployment can reach the intended Preview Supabase project. +2. The `user-settings` Edge Function deployed to that project includes the request-scoped `/auth/ensure-user` path. +3. The Edge Function runtime has `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` set alongside the privileged service-role key used for the narrowly labelled ensure-user reconciliation write. +4. The browser console does not show `Failed to finish authenticated user provisioning: Unauthorized` after OTP verification. + +If OTP verification succeeds but provisioning stays on the modal with `Unauthorized`, stop the smoke, keep pay-link creation blocked, and repair/deploy the Preview Edge Function boundary before retrying. + ## Production smoke steps Run these immediately after deploy against the production host. From a3ec41f0dfe630c32ab8b1891e1f5dd15882afe6 Mon Sep 17 00:00:00 2001 From: KazanderDad <98373366+KazanderDad@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:39:55 -0400 Subject: [PATCH 18/21] [codex] Repair Preview user-settings and pay-link Edge deploy Squash merge PR #97 after green CI. Routes wallet-critical Edge Function deployment through the gated TCOIN Supabase workflow and repairs Preview user-settings runtime compatibility. --- .github/copilot-instructions.md | 4 +- .github/workflows/supabase-deploy-tcoin.yml | 73 +++++++++++++++++++ README.md | 2 +- ...2026-07-20-edge-publishable-key-runtime.md | 28 +++++++ docs/engineering/technical-spec.md | 2 +- docs/engineering/wallet-release-runbook.md | 6 +- supabase/functions/.env.example | 11 ++- supabase/functions/_shared/auth.test.ts | 58 +++++++++++++-- supabase/functions/_shared/auth.ts | 44 ++++++++++- supabase/functions/_shared/cors.test.ts | 53 ++++++++++++++ supabase/functions/_shared/cors.ts | 4 + 11 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 agent-context/session-log/2026-07-20-edge-publishable-key-runtime.md create mode 100644 supabase/functions/_shared/cors.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 72de3cb..8c719da 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -206,8 +206,8 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_verified boolean DEFAULT false; ALTER TABLE users DROP COLUMN IF EXISTS phone_verified; ``` -### Database Workflows -- `supabase-deploy-tcoin.yml` - Dry-run migrations on PRs to `dev`/`main`, and deploy migrations after merges to those branches. +### Database And Edge Function Workflows +- `supabase-deploy-tcoin.yml` - Dry-run migrations on PRs to `dev`/`main`, report the wallet-critical Edge Function deployment plan on PRs, and deploy migrations plus `user-settings`, `payment-links`, and `payment-requests` after merges to those branches. - `db-pull-env.yml` - Manually generate drift migrations from the selected TCOIN Supabase environment. TCOIN Supabase deploys use GitHub Environments `Preview – tcoin` and `Production – tcoin`, plus these secrets: diff --git a/.github/workflows/supabase-deploy-tcoin.yml b/.github/workflows/supabase-deploy-tcoin.yml index a8a2185..142656b 100644 --- a/.github/workflows/supabase-deploy-tcoin.yml +++ b/.github/workflows/supabase-deploy-tcoin.yml @@ -6,10 +6,12 @@ on: types: [opened, synchronize, reopened] paths: - "supabase/migrations/**" + - "supabase/functions/**" push: branches: [dev, main] paths: - "supabase/migrations/**" + - "supabase/functions/**" workflow_dispatch: inputs: target: @@ -28,6 +30,11 @@ on: options: - dry-run - deploy + deploy_edge_functions: + type: boolean + description: Deploy wallet-critical TCOIN Edge Functions after migration handling? + required: false + default: true jobs: preview: @@ -46,12 +53,18 @@ jobs: TARGET_LABEL: Preview – tcoin SUPABASE_DB_URL: ${{ secrets.SUPABASE_SESSION_POOLER_TCOIN_PREVIEW || secrets.SUPABASE_SESSION_POOLER_DEV }} SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN_TCOIN_PREVIEW || secrets.SUPABASE_ACCESS_TOKEN }} + NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL || secrets.NEXT_PUBLIC_SUPABASE_URL }} RUN_MODE: ${{ github.event_name == 'pull_request' && 'dry-run' || github.event_name == 'push' && 'deploy' || github.event.inputs.mode }} + DEPLOY_EDGE_FUNCTIONS: ${{ github.event_name == 'pull_request' && 'false' || github.event_name == 'push' && 'true' || github.event.inputs.deploy_edge_functions }} DB_URL_SECRET_NAME: SUPABASE_SESSION_POOLER_TCOIN_PREVIEW or SUPABASE_SESSION_POOLER_DEV ACCESS_TOKEN_SECRET_NAME: SUPABASE_ACCESS_TOKEN_TCOIN_PREVIEW or SUPABASE_ACCESS_TOKEN + SUPABASE_URL_SECRET_NAME: NEXT_PUBLIC_SUPABASE_URL + TCOIN_EDGE_FUNCTIONS: user-settings payment-links payment-requests steps: - uses: actions/checkout@v4 - uses: supabase/setup-cli@v1 + with: + version: 2.101.0 - name: Check required secrets run: | @@ -65,6 +78,10 @@ jobs: echo "::error::Missing GitHub secret ${ACCESS_TOKEN_SECRET_NAME} for ${TARGET_LABEL}." missing=1 fi + if [ "${DEPLOY_EDGE_FUNCTIONS:-false}" = "true" ] && [ -z "${NEXT_PUBLIC_SUPABASE_URL:-}" ]; then + echo "::error::Missing GitHub var or secret ${SUPABASE_URL_SECRET_NAME} for ${TARGET_LABEL} Edge Function deployment." + missing=1 + fi exit "$missing" - name: Run Supabase migration command @@ -86,6 +103,29 @@ jobs: esac supabase "${args[@]}" + - name: Deploy wallet-critical Edge Functions + if: env.DEPLOY_EDGE_FUNCTIONS == 'true' + run: | + set -euo pipefail + project_ref="$(node -e "const url = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL); console.log(url.hostname.split('.')[0]);")" + if [ -z "$project_ref" ]; then + echo "::error::Unable to derive Supabase project ref from NEXT_PUBLIC_SUPABASE_URL." + exit 1 + fi + + echo "Deploying wallet-critical Edge Functions for ${TARGET_LABEL}: ${TCOIN_EDGE_FUNCTIONS}" + supabase functions deploy ${TCOIN_EDGE_FUNCTIONS} \ + --project-ref "$project_ref" \ + --no-verify-jwt \ + --use-api + + - name: Report Edge Function deployment plan + if: env.DEPLOY_EDGE_FUNCTIONS != 'true' + run: | + set -euo pipefail + echo "PR validation does not deploy Edge Functions." + echo "On merge/push to dev or main, this workflow deploys: ${TCOIN_EDGE_FUNCTIONS}" + production: name: Production TCOIN migrations if: >- @@ -102,12 +142,18 @@ jobs: TARGET_LABEL: Production – tcoin SUPABASE_DB_URL: ${{ secrets.SUPABASE_SESSION_POOLER_TCOIN_PRODUCTION || secrets.SUPABASE_SESSION_POOLER_PROD }} SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN_TCOIN_PRODUCTION || secrets.SUPABASE_ACCESS_TOKEN }} + NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL || secrets.NEXT_PUBLIC_SUPABASE_URL }} RUN_MODE: ${{ github.event_name == 'pull_request' && 'dry-run' || github.event_name == 'push' && 'deploy' || github.event.inputs.mode }} + DEPLOY_EDGE_FUNCTIONS: ${{ github.event_name == 'pull_request' && 'false' || github.event_name == 'push' && 'true' || github.event.inputs.deploy_edge_functions }} DB_URL_SECRET_NAME: SUPABASE_SESSION_POOLER_TCOIN_PRODUCTION or SUPABASE_SESSION_POOLER_PROD ACCESS_TOKEN_SECRET_NAME: SUPABASE_ACCESS_TOKEN_TCOIN_PRODUCTION or SUPABASE_ACCESS_TOKEN + SUPABASE_URL_SECRET_NAME: NEXT_PUBLIC_SUPABASE_URL + TCOIN_EDGE_FUNCTIONS: user-settings payment-links payment-requests steps: - uses: actions/checkout@v4 - uses: supabase/setup-cli@v1 + with: + version: 2.101.0 - name: Check required secrets run: | @@ -121,6 +167,10 @@ jobs: echo "::error::Missing GitHub secret ${ACCESS_TOKEN_SECRET_NAME} for ${TARGET_LABEL}." missing=1 fi + if [ "${DEPLOY_EDGE_FUNCTIONS:-false}" = "true" ] && [ -z "${NEXT_PUBLIC_SUPABASE_URL:-}" ]; then + echo "::error::Missing GitHub var or secret ${SUPABASE_URL_SECRET_NAME} for ${TARGET_LABEL} Edge Function deployment." + missing=1 + fi exit "$missing" - name: Run Supabase migration command @@ -141,3 +191,26 @@ jobs: ;; esac supabase "${args[@]}" + + - name: Deploy wallet-critical Edge Functions + if: env.DEPLOY_EDGE_FUNCTIONS == 'true' + run: | + set -euo pipefail + project_ref="$(node -e "const url = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL); console.log(url.hostname.split('.')[0]);")" + if [ -z "$project_ref" ]; then + echo "::error::Unable to derive Supabase project ref from NEXT_PUBLIC_SUPABASE_URL." + exit 1 + fi + + echo "Deploying wallet-critical Edge Functions for ${TARGET_LABEL}: ${TCOIN_EDGE_FUNCTIONS}" + supabase functions deploy ${TCOIN_EDGE_FUNCTIONS} \ + --project-ref "$project_ref" \ + --no-verify-jwt \ + --use-api + + - name: Report Edge Function deployment plan + if: env.DEPLOY_EDGE_FUNCTIONS != 'true' + run: | + set -euo pipefail + echo "PR validation does not deploy Edge Functions." + echo "On merge/push to dev or main, this workflow deploys: ${TCOIN_EDGE_FUNCTIONS}" diff --git a/README.md b/README.md index 497e91c..a4df3a7 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Do not put real secrets in checked-in files. Keep `SUPABASE_SERVICE_ROLE_KEY` ou ## CI And Database Delivery -Pull requests run frontend CI, secret scanning, and Supabase migration validation when relevant. The dedicated TCOIN Supabase workflow dry-runs migrations for PRs into `dev` against `Preview – tcoin` and PRs into `main` against `Production – tcoin`; pushes to those branches deploy migrations to the matching database after the GitHub Environment gate. +Pull requests run frontend CI, secret scanning, and Supabase validation when relevant. The dedicated TCOIN Supabase workflow dry-runs migrations for PRs into `dev` against `Preview – tcoin` and PRs into `main` against `Production – tcoin`; it also reports the wallet-critical Edge Function deployment plan. Pushes to those branches deploy migrations and the `user-settings`, `payment-links`, and `payment-requests` Edge Functions to the matching Supabase project after the GitHub Environment gate. Agents must never directly mutate a linked Supabase database. Remote schema changes should flow through reviewed migrations and the guarded GitHub workflow, or through an explicit human/operator action described in the relevant runbook. diff --git a/agent-context/session-log/2026-07-20-edge-publishable-key-runtime.md b/agent-context/session-log/2026-07-20-edge-publishable-key-runtime.md new file mode 100644 index 0000000..608a458 --- /dev/null +++ b/agent-context/session-log/2026-07-20-edge-publishable-key-runtime.md @@ -0,0 +1,28 @@ +# Session Log - codex/edge-publishable-key-runtime + +## 2026-07-20T19:07:00Z - Repair Preview Edge publishable-key runtime + +- agent: Codex +- branch: codex/edge-publishable-key-runtime +- head: 99f74c6 +- summary: Cleaned up after merged PR #96 by fast-forwarding `dev`, deleting the merged local and remote branch, and preserving Task #86 as in-progress until hosted OTP smoke was green. With explicit operator approval for the Preview Supabase `user-settings` Edge Function/runtime, inspected the `GeneroDev` project, confirmed `user-settings` was active, deployed the current function code, and found the project account can read but not manage Edge Function secrets. Patched the shared Edge auth helper so request-scoped clients can use Supabase's hosted `SUPABASE_PUBLISHABLE_KEYS` default secret when the custom `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` alias is unavailable, with legacy `SUPABASE_ANON_KEY` only as the final platform fallback. Also added exact-match CORS fallbacks for the standard TCOIN Preview and launch hosts so Preview can authenticate without wildcard origin reflection when custom Edge secrets are not manageable. +- validation: `supabase functions list --project-ref uyopiuwmlhbevoxmfvfx --output json`; `supabase functions deploy user-settings --project-ref uyopiuwmlhbevoxmfvfx --no-verify-jwt` deployed Preview `user-settings` version 13; `supabase secrets list --project-ref uyopiuwmlhbevoxmfvfx --output json`; Supabase dashboard confirmed additional permissions are required to manage custom Edge secrets; `pnpm exec vitest run supabase/functions/_shared/auth.test.ts`; `pnpm exec vitest run supabase/functions/_shared/auth.test.ts supabase/functions/_shared/cors.test.ts`; Chrome hosted smoke signed out, requested a fresh OTP for the approved test account, submitted it, reached the authenticated welcome/setup surface, and then loaded `/tcoin/wallet/dashboard` with authenticated wallet content. +- follow-ups: Commit and publish the helper patch. Move Task #86 to `On dev` with sanitized evidence, then start Task #87 pay-link create/resolve smoke against Preview. Keep any additional Edge origins in `USER_SETTINGS_ALLOWED_ORIGINS` instead of broadening the exact-match fallback list. + +## 2026-07-20T19:34:00Z - Route Preview pay-link function deployment through CI + +- agent: Codex +- branch: codex/edge-publishable-key-runtime +- head: f7dffb2 +- summary: Continued Goal #79 Task #87 using the orchestrator flow. Preview signed-in state loaded in Chrome, but Receive-tab pay-link minting failed with browser `Failed to fetch`. Read-only OPTIONS checks showed `user-settings` now returns the corrected TCOIN Preview CORS headers while `payment-links` returns `404 Requested function was not found`, proving the Preview project is missing the deployed pay-link Edge Function. Per operator guidance, did not deploy linked Supabase directly; instead updated PR #97 so the gated `supabase-deploy-tcoin.yml` workflow deploys the wallet-critical Edge Function bundle (`user-settings`, `payment-links`, `payment-requests`) on merges to `dev`/`main` and only reports the plan on PRs. +- validation: Read-only `curl -i -X OPTIONS` checks for Preview `user-settings` and `payment-links`; `supabase functions list --project-ref uyopiuwmlhbevoxmfvfx --output json`; `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/supabase-deploy-tcoin.yml")'`; `pnpm exec vitest run supabase/functions/_shared/auth.test.ts supabase/functions/_shared/cors.test.ts supabase/functions/payment-links/index.test.ts supabase/functions/payment-requests/index.test.ts`; `pnpm lint`; `pnpm exec tsc --noEmit --pretty false`; `git diff --check`. +- follow-ups: Merge #97 through normal review/CI so the Preview push deploys `payment-links` and `payment-requests`; then rerun #87 pay-link create/resolve smoke against Preview before starting Goal #81. + +## 2026-07-20T19:39:00Z - Pin Supabase CLI for PR migration dry-runs + +- agent: Codex +- branch: codex/edge-publishable-key-runtime +- head: 7e1350f +- summary: Addressed the failing `Preview TCOIN migrations` check on PR #97. The job used the default `supabase/setup-cli@v1` CLI version `2.20.3`, which failed before dry-run with `Invalid db.major_version: 17`; pinned both Preview and Production Supabase deploy jobs to CLI `2.101.0`, matching the local version that parses the repo config successfully. +- validation: GitHub job log for `Preview TCOIN migrations`; `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/supabase-deploy-tcoin.yml")'`; `git diff --check`. +- follow-ups: Push the fix and wait for PR #97 checks to rerun before requesting review/merge. diff --git a/docs/engineering/technical-spec.md b/docs/engineering/technical-spec.md index 2f449ed..78a018d 100644 --- a/docs/engineering/technical-spec.md +++ b/docs/engineering/technical-spec.md @@ -84,7 +84,7 @@ - The local Supabase smoke helper is intentionally launched with `zsh scripts/start-local-supabase.sh`, matching the script shebang and array-based shell syntax instead of assuming bash compatibility. - `docs/engineering/wallet-release-runbook.md` is now the canonical wallet go-live checklist. It captures the checked-in env matrix, repo preflight commands, local and production smoke steps, pay-link cleanup cron verification, indexer health expectations, and rollback guidance. - `.github/workflows/release-alignment-tcoin.yml` provides the CI-assisted remote alignment layer after successful Supabase migration deploys on `dev` and `main`. It is gated by the `Preview – tcoin` and `Production – tcoin` GitHub Environments, reloads PostgREST, runs deployment-profile wallet preflight, runs TorontoCoin ops checks, and optionally runs the Playwright smoke harness when `SMOKE_BASE_URL` is configured. - - TCOIN Supabase migration delivery now runs through one dedicated workflow, `supabase-deploy-tcoin.yml`. PRs into `dev` dry-run against the Preview TCOIN session-pooler database, PRs into `main` dry-run against the Production TCOIN session-pooler database, pushes to `dev` deploy to Preview, and pushes to `main` deploy to Production. + - TCOIN Supabase delivery now runs through one dedicated workflow, `supabase-deploy-tcoin.yml`. PRs into `dev` dry-run migrations against the Preview TCOIN session-pooler database and report the Edge Function deployment plan; PRs into `main` do the same against Production. Pushes to `dev` deploy migrations plus the wallet-critical Edge Function bundle (`user-settings`, `payment-links`, `payment-requests`) to Preview, and pushes to `main` deploy the same bundle to Production. - Supabase deploy jobs are gated by GitHub Environments `Preview – tcoin` and `Production – tcoin`, use serialized concurrency groups per target, and require the explicit secret pairs `SUPABASE_SESSION_POOLER_TCOIN_PREVIEW` / `SUPABASE_ACCESS_TOKEN_TCOIN_PREVIEW` and `SUPABASE_SESSION_POOLER_TCOIN_PRODUCTION` / `SUPABASE_ACCESS_TOKEN_TCOIN_PRODUCTION`. ## Architecture diff --git a/docs/engineering/wallet-release-runbook.md b/docs/engineering/wallet-release-runbook.md index 60ae287..d3786e2 100644 --- a/docs/engineering/wallet-release-runbook.md +++ b/docs/engineering/wallet-release-runbook.md @@ -22,9 +22,9 @@ Env template references: | Area | Env vars | Why it matters | | --- | --- | --- | | Core Supabase runtime | `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | Required by browser auth, edge-function proxying, and the publishable-key wallet release health preflight. | -| Worker and privileged function runtime | `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | The deployed Next.js wallet runtime does not need the service-role key. Edge Functions need the publishable key for request-scoped self-service RPCs and scoped identity/app-context resolution. The service-role key remains required only for explicit route-specific privileged Edge operations, webhook/settlement paths, custody/transfer bookkeeping, and the external indexer touch worker (`pnpm ops:indexer:drain-touch-queue`). | +| Worker and privileged function runtime | `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` or Supabase's default `SUPABASE_PUBLISHABLE_KEYS` | The deployed Next.js wallet runtime does not need the service-role key. Edge Functions need a publishable key for request-scoped self-service RPCs and scoped identity/app-context resolution; hosted Supabase functions can use the platform-provided publishable-key dictionary without a custom alias. The service-role key remains required only for explicit route-specific privileged Edge operations, webhook/settlement paths, custody/transfer bookkeeping, and the external indexer touch worker (`pnpm ops:indexer:drain-touch-queue`). | | App scoping | `NEXT_PUBLIC_CITYCOIN=tcoin`, `NEXT_PUBLIC_APP_NAME=wallet`, `NEXT_PUBLIC_APP_ENVIRONMENT=staging|production` | Controls app-instance scoping, auth bootstrap behaviour, and production-versus-local auth rules. | -| Public wallet URLing | `NEXT_PUBLIC_WALLET_PUBLIC_BASE_URL`, `NEXT_PUBLIC_SITE_URL`, `USER_SETTINGS_ALLOWED_ORIGINS` | Required for pay-link generation, edge CORS, and public callback/origin checks. | +| Public wallet URLing | `NEXT_PUBLIC_WALLET_PUBLIC_BASE_URL`, `NEXT_PUBLIC_SITE_URL`, `USER_SETTINGS_ALLOWED_ORIGINS` | Required for pay-link generation, edge CORS, and public callback/origin checks. The `user-settings` Edge Function has exact-match fallbacks for local development, the standard TCOIN Preview Vercel hosts, and the public `tcoin.me` launch hosts; configure `USER_SETTINGS_ALLOWED_ORIGINS` for any additional deployment URLs. | | Wallet user experience | `NEXT_PUBLIC_CITYCOIN_CAD_FALLBACK_RATE`, `NEXT_PUBLIC_EXPLORER_URL`, `NEXT_PUBLIC_TCOIN_BANNER_LIGHT_URL`, `NEXT_PUBLIC_TCOIN_BANNER_DARK_URL` | Keeps public landing and authenticated wallet flows coherent when exchange-rate or explorer data is needed. | | Indexer runtime | `INDEXER_CHAIN_ID`, `INDEXER_CHAIN_RPC_URL`, `INDEXER_INITIAL_BLOCK`, `INDEXER_MAX_BLOCKS_PER_RUN`, `INDEXER_DISCOVERY_POOL_LIMIT` | Drives `/api/indexer/touch`, `/api/indexer/status`, wallet stats, and operator health. | @@ -249,7 +249,7 @@ Preview signed-in smoke requires the deployed Next.js build and the Preview Supa 1. The Preview deployment can reach the intended Preview Supabase project. 2. The `user-settings` Edge Function deployed to that project includes the request-scoped `/auth/ensure-user` path. -3. The Edge Function runtime has `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` set alongside the privileged service-role key used for the narrowly labelled ensure-user reconciliation write. +3. The Edge Function runtime has either `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` set or Supabase's default `SUPABASE_PUBLISHABLE_KEYS` available alongside the privileged service-role key used for the narrowly labelled ensure-user reconciliation write. 4. The browser console does not show `Failed to finish authenticated user provisioning: Unauthorized` after OTP verification. If OTP verification succeeds but provisioning stays on the modal with `Unauthorized`, stop the smoke, keep pay-link creation blocked, and repair/deploy the Preview Edge Function boundary before retrying. diff --git a/supabase/functions/.env.example b/supabase/functions/.env.example index d7b97a0..b12cac5 100644 --- a/supabase/functions/.env.example +++ b/supabase/functions/.env.example @@ -18,8 +18,10 @@ SUPABASE_URL= # Example: eyJhbGciOi... (service_role key from Supabase project settings) SUPABASE_SERVICE_ROLE_KEY= -# Publishable key used by request-scoped Edge clients for authenticated -# self-service RPC calls that do not need service-role privileges. +# Optional canonical publishable key used by request-scoped Edge clients for +# authenticated self-service RPC calls that do not need service-role privileges. +# Hosted Supabase functions can also use the platform-provided +# SUPABASE_PUBLISHABLE_KEYS default secret instead of this custom alias. # Example: sb_publishable_... NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= @@ -43,7 +45,10 @@ SITE_URL=https://www.tcoin.me # Comma-separated frontend origins allowed to call authenticated edge functions. # Include every deployed wallet or sparechange origin plus any local hosts used -# during development. +# during development. The runtime also has exact-match fallbacks for local +# development, the standard TCOIN Preview dev/main Vercel hosts, and the +# public `tcoin.me` launch hosts; use this env for any additional deployment +# URLs. # Example: http://localhost:3000,http://127.0.0.1:3000,https://wallet.example.com USER_SETTINGS_ALLOWED_ORIGINS= diff --git a/supabase/functions/_shared/auth.test.ts b/supabase/functions/_shared/auth.test.ts index 54b7e4e..3cf5bda 100644 --- a/supabase/functions/_shared/auth.test.ts +++ b/supabase/functions/_shared/auth.test.ts @@ -4,14 +4,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const rpcMock = vi.hoisted(() => vi.fn()); const getUserMock = vi.hoisted(() => vi.fn()); const createClientMock = vi.hoisted(() => vi.fn(() => ({ auth: { getUser: getUserMock }, rpc: rpcMock }))); +const denoEnvValues = vi.hoisted(() => ({ + SUPABASE_URL: "https://project.supabase.co", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + SUPABASE_SERVICE_ROLE_KEY: "service-role-test", +} as Record)); const denoEnv = vi.hoisted(() => { const env = { get(name: string) { - return { - SUPABASE_URL: "https://project.supabase.co", - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", - SUPABASE_SERVICE_ROLE_KEY: "service-role-test", - }[name]; + return denoEnvValues[name]; }, }; (globalThis as any).Deno = { env }; @@ -33,6 +34,12 @@ import { describe("edge auth client boundaries", () => { beforeEach(() => { vi.clearAllMocks(); + denoEnvValues.SUPABASE_URL = "https://project.supabase.co"; + denoEnvValues.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = "sb_publishable_test"; + denoEnvValues.SUPABASE_PUBLISHABLE_KEYS = undefined; + denoEnvValues.SUPABASE_PUBLISHABLE_KEY = undefined; + denoEnvValues.SUPABASE_ANON_KEY = undefined; + denoEnvValues.SUPABASE_SERVICE_ROLE_KEY = "service-role-test"; (globalThis as any).Deno = { env: denoEnv }; rpcMock.mockReset(); getUserMock.mockReset(); @@ -58,6 +65,47 @@ describe("edge auth client boundaries", () => { ); }); + it("uses Supabase's hosted publishable-key dictionary when no custom canonical secret is set", () => { + denoEnvValues.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = undefined; + denoEnvValues.SUPABASE_PUBLISHABLE_KEYS = JSON.stringify({ + default: { + api_key: "sb_publishable_hosted_default", + }, + }); + + createAuthenticatedRequestClient( + new Request("http://localhost/functions/v1/test", { + headers: { authorization: "Bearer caller-token" }, + }), + { purpose: "unit test hosted scoped read" } + ); + + expect(createClientMock).toHaveBeenCalledWith( + "https://project.supabase.co", + "sb_publishable_hosted_default", + expect.any(Object) + ); + }); + + it("falls back to the platform anon key only when publishable keys are unavailable", () => { + denoEnvValues.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = undefined; + denoEnvValues.SUPABASE_PUBLISHABLE_KEYS = undefined; + denoEnvValues.SUPABASE_ANON_KEY = "legacy-anon-key"; + + createAuthenticatedRequestClient( + new Request("http://localhost/functions/v1/test", { + headers: { authorization: "Bearer caller-token" }, + }), + { purpose: "unit test legacy scoped read" } + ); + + expect(createClientMock).toHaveBeenCalledWith( + "https://project.supabase.co", + "legacy-anon-key", + expect.any(Object) + ); + }); + it("requires explicit purpose labels for privileged service-role clients", () => { expect(() => createServiceRoleClient()).toThrow("service-role purpose"); }); diff --git a/supabase/functions/_shared/auth.ts b/supabase/functions/_shared/auth.ts index 5183062..6099bbe 100644 --- a/supabase/functions/_shared/auth.ts +++ b/supabase/functions/_shared/auth.ts @@ -30,6 +30,48 @@ function requireFirstEnv(names: string[]): string { throw new Error(`${names.join(" or ")} is required.`); } +function resolvePublishableKeyFromDictionary(rawValue: string): string | null { + try { + const parsed = JSON.parse(rawValue) as unknown; + const candidates: unknown[] = Array.isArray(parsed) ? parsed : Object.values((parsed ?? {}) as Record); + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.startsWith("sb_publishable_")) { + return candidate; + } + + if (candidate && typeof candidate === "object") { + for (const nested of Object.values(candidate as Record)) { + if (typeof nested === "string" && nested.startsWith("sb_publishable_")) { + return nested; + } + } + } + } + } catch { + return null; + } + + return null; +} + +function resolveEdgePublishableKey(): string { + const canonical = DenoRuntime?.env.get("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY"); + if (canonical) { + return canonical; + } + + const publishableKeys = DenoRuntime?.env.get("SUPABASE_PUBLISHABLE_KEYS"); + if (publishableKeys) { + const key = resolvePublishableKeyFromDictionary(publishableKeys); + if (key) { + return key; + } + } + + return requireFirstEnv(["SUPABASE_PUBLISHABLE_KEY", "SUPABASE_ANON_KEY"]); +} + export function createServiceRoleClient(options?: { purpose?: string }) { const purpose = options?.purpose?.trim(); if (!purpose) { @@ -54,7 +96,7 @@ export function createAuthenticatedRequestClient(req: Request, options?: { purpo return createClient( requireFirstEnv(["SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL"]), - requireEnv("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY"), + resolveEdgePublishableKey(), { auth: { autoRefreshToken: false, diff --git a/supabase/functions/_shared/cors.test.ts b/supabase/functions/_shared/cors.test.ts new file mode 100644 index 0000000..3777981 --- /dev/null +++ b/supabase/functions/_shared/cors.test.ts @@ -0,0 +1,53 @@ +/** @vitest-environment node */ +import { describe, expect, it, vi } from "vitest"; + +const denoEnvValues = vi.hoisted(() => ({} as Record)); + +vi.hoisted(() => { + (globalThis as any).Deno = { + env: { + get(name: string) { + return denoEnvValues[name]; + }, + }, + }; +}); + +import { resolveCorsHeaders } from "./cors"; + +describe("edge CORS headers", () => { + it("allows the known TCOIN Preview dev host without a custom Edge secret", () => { + const headers = resolveCorsHeaders( + new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { + headers: { origin: "https://tcoin-git-dev-cubid-team.vercel.app" }, + }) + ); + + expect(headers["Access-Control-Allow-Origin"]).toBe("https://tcoin-git-dev-cubid-team.vercel.app"); + expect(headers.Vary).toBe("Origin"); + }); + + it("does not reflect arbitrary origins", () => { + const headers = resolveCorsHeaders( + new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { + headers: { origin: "https://example.invalid" }, + }) + ); + + expect(headers["Access-Control-Allow-Origin"]).toBeUndefined(); + }); + + it("allows custom origins from runtime env", () => { + denoEnvValues.USER_SETTINGS_ALLOWED_ORIGINS = "https://preview.example.test"; + + const headers = resolveCorsHeaders( + new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { + headers: { origin: "https://preview.example.test" }, + }) + ); + + expect(headers["Access-Control-Allow-Origin"]).toBe("https://preview.example.test"); + + denoEnvValues.USER_SETTINGS_ALLOWED_ORIGINS = undefined; + }); +}); diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts index 6f3c94a..eba842c 100644 --- a/supabase/functions/_shared/cors.ts +++ b/supabase/functions/_shared/cors.ts @@ -3,6 +3,10 @@ const DEFAULT_ALLOWED_ORIGINS = [ "http://127.0.0.1:3000", "http://localhost:3001", "http://127.0.0.1:3001", + "https://tcoin-git-dev-cubid-team.vercel.app", + "https://tcoin-git-main-cubid-team.vercel.app", + "https://tcoin.me", + "https://www.tcoin.me", ]; function readAllowedOriginsFromEnv(): string[] { From 5c9198aa20a79c190de7232fb52856cb69106a19 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 16:33:32 -0400 Subject: [PATCH 19/21] ci(supabase): address post-merge review feedback Objective: - correct the valid Codex review feedback that landed after PR #97 was merged Changes: - make manual Supabase workflow dry-runs non-mutating by default - gate manual Edge Function deploys on explicit deploy mode plus deploy toggle - deploy wallet-critical Edge Functions one at a time through supported CLI calls - record the corrective follow-up in the dev session log Validation: - ruby -e 'require "yaml"; YAML.load_file(".github/workflows/supabase-deploy-tcoin.yml"); puts "ok"' - git diff --check Notes: - PR #97 merged before the Codex review was submitted, but the feedback was still valid and is fixed here on dev. --- .github/workflows/supabase-deploy-tcoin.yml | 28 +++++++++++++-------- agent-context/session-log/dev.md | 9 +++++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.github/workflows/supabase-deploy-tcoin.yml b/.github/workflows/supabase-deploy-tcoin.yml index 142656b..e2737b1 100644 --- a/.github/workflows/supabase-deploy-tcoin.yml +++ b/.github/workflows/supabase-deploy-tcoin.yml @@ -34,7 +34,7 @@ on: type: boolean description: Deploy wallet-critical TCOIN Edge Functions after migration handling? required: false - default: true + default: false jobs: preview: @@ -55,7 +55,7 @@ jobs: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN_TCOIN_PREVIEW || secrets.SUPABASE_ACCESS_TOKEN }} NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL || secrets.NEXT_PUBLIC_SUPABASE_URL }} RUN_MODE: ${{ github.event_name == 'pull_request' && 'dry-run' || github.event_name == 'push' && 'deploy' || github.event.inputs.mode }} - DEPLOY_EDGE_FUNCTIONS: ${{ github.event_name == 'pull_request' && 'false' || github.event_name == 'push' && 'true' || github.event.inputs.deploy_edge_functions }} + DEPLOY_EDGE_FUNCTIONS: ${{ github.event_name == 'pull_request' && 'false' || github.event_name == 'push' && 'true' || (github.event.inputs.mode == 'deploy' && github.event.inputs.deploy_edge_functions == 'true' && 'true' || 'false') }} DB_URL_SECRET_NAME: SUPABASE_SESSION_POOLER_TCOIN_PREVIEW or SUPABASE_SESSION_POOLER_DEV ACCESS_TOKEN_SECRET_NAME: SUPABASE_ACCESS_TOKEN_TCOIN_PREVIEW or SUPABASE_ACCESS_TOKEN SUPABASE_URL_SECRET_NAME: NEXT_PUBLIC_SUPABASE_URL @@ -114,10 +114,13 @@ jobs: fi echo "Deploying wallet-critical Edge Functions for ${TARGET_LABEL}: ${TCOIN_EDGE_FUNCTIONS}" - supabase functions deploy ${TCOIN_EDGE_FUNCTIONS} \ - --project-ref "$project_ref" \ - --no-verify-jwt \ - --use-api + for function_name in ${TCOIN_EDGE_FUNCTIONS}; do + echo "Deploying ${function_name}." + supabase functions deploy "$function_name" \ + --project-ref "$project_ref" \ + --no-verify-jwt \ + --use-api + done - name: Report Edge Function deployment plan if: env.DEPLOY_EDGE_FUNCTIONS != 'true' @@ -144,7 +147,7 @@ jobs: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN_TCOIN_PRODUCTION || secrets.SUPABASE_ACCESS_TOKEN }} NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL || secrets.NEXT_PUBLIC_SUPABASE_URL }} RUN_MODE: ${{ github.event_name == 'pull_request' && 'dry-run' || github.event_name == 'push' && 'deploy' || github.event.inputs.mode }} - DEPLOY_EDGE_FUNCTIONS: ${{ github.event_name == 'pull_request' && 'false' || github.event_name == 'push' && 'true' || github.event.inputs.deploy_edge_functions }} + DEPLOY_EDGE_FUNCTIONS: ${{ github.event_name == 'pull_request' && 'false' || github.event_name == 'push' && 'true' || (github.event.inputs.mode == 'deploy' && github.event.inputs.deploy_edge_functions == 'true' && 'true' || 'false') }} DB_URL_SECRET_NAME: SUPABASE_SESSION_POOLER_TCOIN_PRODUCTION or SUPABASE_SESSION_POOLER_PROD ACCESS_TOKEN_SECRET_NAME: SUPABASE_ACCESS_TOKEN_TCOIN_PRODUCTION or SUPABASE_ACCESS_TOKEN SUPABASE_URL_SECRET_NAME: NEXT_PUBLIC_SUPABASE_URL @@ -203,10 +206,13 @@ jobs: fi echo "Deploying wallet-critical Edge Functions for ${TARGET_LABEL}: ${TCOIN_EDGE_FUNCTIONS}" - supabase functions deploy ${TCOIN_EDGE_FUNCTIONS} \ - --project-ref "$project_ref" \ - --no-verify-jwt \ - --use-api + for function_name in ${TCOIN_EDGE_FUNCTIONS}; do + echo "Deploying ${function_name}." + supabase functions deploy "$function_name" \ + --project-ref "$project_ref" \ + --no-verify-jwt \ + --use-api + done - name: Report Edge Function deployment plan if: env.DEPLOY_EDGE_FUNCTIONS != 'true' diff --git a/agent-context/session-log/dev.md b/agent-context/session-log/dev.md index ca57dfe..e50d15b 100644 --- a/agent-context/session-log/dev.md +++ b/agent-context/session-log/dev.md @@ -1,5 +1,14 @@ # Session Log - dev +## 2026-07-20T20:32:55Z - Follow up on PR #97 post-merge review comments + +- agent: Codex +- branch: dev +- head: a3ec41f +- summary: Audited PR #97 after the user flagged that Codex review comments appeared unaddressed. The PR was merged at 2026-07-20T19:39:55Z and the Codex review landed later at 2026-07-20T19:44:28Z, but the comments were valid workflow hardening items. Patched the TCOIN Supabase deploy workflow so manual dry-runs cannot deploy Edge Functions by default and so wallet-critical Edge Functions deploy one at a time through supported Supabase CLI invocations. +- validation: `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/supabase-deploy-tcoin.yml"); puts "ok"'`; `git diff --check`. +- follow-ups: Commit and push this direct `dev` follow-up, then reply to both PR #97 review threads with the corrective commit. Future PR merge flow should wait for Codex review completion after requesting it, even when CI is already green. + ## 2026-07-20T17:59:38Z - Capture Preview Edge alignment blocker for OTP smoke - agent: Codex From 6f9d7ce1b97ed1f00cd18b1e877343618ce80652 Mon Sep 17 00:00:00 2001 From: Noak Date: Mon, 20 Jul 2026 23:51:40 -0400 Subject: [PATCH 20/21] fix(edge): allow owned Vercel preview origins Objective: - unblock authenticated wallet bootstrap on hashed Vercel Preview deployments Changes: - allow HTTPS Vercel preview origins for the known tcoin and spare-change Cubid-team hosts - keep arbitrary origins, HTTP previews, and lookalike hostnames rejected - add CORS regression coverage for hashed and git-style preview URLs - record the diagnosis and validation in the branch session log Validation: - pnpm exec vitest run supabase/functions/_shared/cors.test.ts supabase/functions/_shared/auth.test.ts supabase/functions/user-settings/index.test.ts - pnpm exec tsc --noEmit --pretty false - pnpm lint - git diff --check Notes: - browser console showed user-settings ensure-user preflight missing Access-Control-Allow-Origin for the hashed TCOIN Vercel Preview host; bearer token values were not recorded. --- .../2026-07-21-tcoin-preview-cors-hosts.md | 10 +++++ supabase/functions/_shared/cors.test.ts | 37 ++++++++++++++++--- supabase/functions/_shared/cors.ts | 13 ++++++- 3 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 agent-context/session-log/2026-07-21-tcoin-preview-cors-hosts.md diff --git a/agent-context/session-log/2026-07-21-tcoin-preview-cors-hosts.md b/agent-context/session-log/2026-07-21-tcoin-preview-cors-hosts.md new file mode 100644 index 0000000..7e32894 --- /dev/null +++ b/agent-context/session-log/2026-07-21-tcoin-preview-cors-hosts.md @@ -0,0 +1,10 @@ +# Session Log - tcoin-preview-cors-hosts + +## 2026-07-21T03:51:01Z - Allow TCOIN Vercel preview Edge CORS + +- agent: Codex +- branch: codex/tcoin-preview-cors-hosts +- head: 5c9198a +- summary: Diagnosed a hosted Preview deployment stuck on `...loading` at `https://tcoin-802yqav97-cubid-team.vercel.app/`. The browser console and attached logs showed repeated CORS preflight failures for `user-settings/auth/ensure-user` because the Edge CORS helper only allowed stable dev/main/production hosts, not hashed Vercel deployment preview hosts. Added a narrow HTTPS-only Vercel preview host allow rule for the known `tcoin` and `spare-change` Cubid-team deployment host patterns, while keeping arbitrary origins rejected. +- validation: `pnpm exec vitest run supabase/functions/_shared/cors.test.ts supabase/functions/_shared/auth.test.ts supabase/functions/user-settings/index.test.ts`. +- follow-ups: Open a PR to `dev` and let the gated Supabase workflow deploy the updated Edge Function through CI. After merge, reload the affected Preview deployment and confirm `user-settings/auth/ensure-user` preflight returns `Access-Control-Allow-Origin` for the hashed Vercel host. diff --git a/supabase/functions/_shared/cors.test.ts b/supabase/functions/_shared/cors.test.ts index 3777981..2b65e61 100644 --- a/supabase/functions/_shared/cors.test.ts +++ b/supabase/functions/_shared/cors.test.ts @@ -27,14 +27,39 @@ describe("edge CORS headers", () => { expect(headers.Vary).toBe("Origin"); }); + it("allows Vercel deployment-preview hosts for TCOIN and SpareChange", () => { + for (const origin of [ + "https://tcoin-802yqav97-cubid-team.vercel.app", + "https://tcoin-git-codex-preview-cubid-team.vercel.app", + "https://spare-change-802yqav97-cubid-team.vercel.app", + "https://spare-change-git-codex-preview-cubid-team.vercel.app", + ]) { + const headers = resolveCorsHeaders( + new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { + headers: { origin }, + }) + ); + + expect(headers["Access-Control-Allow-Origin"]).toBe(origin); + expect(headers.Vary).toBe("Origin"); + } + }); + it("does not reflect arbitrary origins", () => { - const headers = resolveCorsHeaders( - new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { - headers: { origin: "https://example.invalid" }, - }) - ); + for (const origin of [ + "https://example.invalid", + "https://tcoin-802yqav97-evil-team.vercel.app", + "https://tcoin-802yqav97-cubid-team.vercel.app.evil.test", + "http://tcoin-802yqav97-cubid-team.vercel.app", + ]) { + const headers = resolveCorsHeaders( + new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { + headers: { origin }, + }) + ); - expect(headers["Access-Control-Allow-Origin"]).toBeUndefined(); + expect(headers["Access-Control-Allow-Origin"]).toBeUndefined(); + } }); it("allows custom origins from runtime env", () => { diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts index eba842c..66740e9 100644 --- a/supabase/functions/_shared/cors.ts +++ b/supabase/functions/_shared/cors.ts @@ -9,6 +9,8 @@ const DEFAULT_ALLOWED_ORIGINS = [ "https://www.tcoin.me", ]; +const VERCEL_PREVIEW_HOST_PATTERN = /^(?:tcoin|spare-change)(?:-[a-z0-9]+|-git-[a-z0-9-]+)-cubid-team\.vercel\.app$/; + function readAllowedOriginsFromEnv(): string[] { const configured = [ Deno.env.get("USER_SETTINGS_ALLOWED_ORIGINS"), @@ -23,6 +25,15 @@ function readAllowedOriginsFromEnv(): string[] { .filter(Boolean); } +function isAllowedVercelPreviewOrigin(origin: string): boolean { + try { + const url = new URL(origin); + return url.protocol === "https:" && VERCEL_PREVIEW_HOST_PATTERN.test(url.hostname); + } catch { + return false; + } +} + function resolveAllowedOrigin(req?: Request): string | null { const requestOrigin = req?.headers.get("origin")?.trim() ?? ""; if (!requestOrigin) { @@ -30,7 +41,7 @@ function resolveAllowedOrigin(req?: Request): string | null { } const allowedOrigins = new Set([...DEFAULT_ALLOWED_ORIGINS, ...readAllowedOriginsFromEnv()]); - return allowedOrigins.has(requestOrigin) ? requestOrigin : null; + return allowedOrigins.has(requestOrigin) || isAllowedVercelPreviewOrigin(requestOrigin) ? requestOrigin : null; } export function resolveCorsHeaders(req?: Request): Record { From bd049ee00fea217f12f8863e8f5cc99550ce328f Mon Sep 17 00:00:00 2001 From: Noak Date: Tue, 21 Jul 2026 00:56:45 -0400 Subject: [PATCH 21/21] fix(edge): allow TCOIN dev CORS origins Objective: - unblock authenticated bootstrap on TCOIN dev custom and Vercel preview hosts Changes: - allow the owned dev.tcoin.me custom domain in Edge CORS - include the tcoin-dev Vercel preview project prefix in the narrow preview-origin matcher - add regression coverage for the new allowed origins and lookalike rejections - record the diagnosis and validation in the branch session log Validation: - pnpm exec vitest run supabase/functions/_shared/cors.test.ts supabase/functions/_shared/auth.test.ts supabase/functions/user-settings/index.test.ts - pnpm exec tsc --noEmit --pretty false - pnpm lint - git diff --check Notes: - deployed preflight checks confirmed the earlier tcoin preview host was fixed by #98, while tcoin-dev-git-dev and dev.tcoin.me were still missing Access-Control-Allow-Origin. --- .../2026-07-21-tcoin-dev-cors-origins.md | 10 ++++++++++ supabase/functions/_shared/cors.test.ts | 15 +++++++++++++++ supabase/functions/_shared/cors.ts | 4 +++- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 agent-context/session-log/2026-07-21-tcoin-dev-cors-origins.md diff --git a/agent-context/session-log/2026-07-21-tcoin-dev-cors-origins.md b/agent-context/session-log/2026-07-21-tcoin-dev-cors-origins.md new file mode 100644 index 0000000..ddc70ea --- /dev/null +++ b/agent-context/session-log/2026-07-21-tcoin-dev-cors-origins.md @@ -0,0 +1,10 @@ +# Session Log - tcoin-dev-cors-origins + +## 2026-07-21T04:56:27Z - Add TCOIN dev CORS origins + +- agent: Codex +- branch: codex/tcoin-dev-cors-origins +- head: 90c5ffe +- summary: Followed up after the same `user-settings/auth/ensure-user` CORS preflight failure appeared on `https://tcoin-dev-git-dev-cubid-team.vercel.app/` and `https://dev.tcoin.me/`. Verified the deployed Edge Function allowed the earlier hashed `tcoin-*` preview origin but did not return `Access-Control-Allow-Origin` for these two dev surfaces. Added the owned `dev.tcoin.me` custom domain and the `tcoin-dev-*` Vercel project host pattern while keeping arbitrary, HTTP, and lookalike origins rejected. +- validation: `pnpm exec vitest run supabase/functions/_shared/cors.test.ts supabase/functions/_shared/auth.test.ts supabase/functions/user-settings/index.test.ts`; `pnpm exec tsc --noEmit --pretty false`; `pnpm lint`; `git diff --check`. +- follow-ups: Open a PR to `dev`, wait for checks/review, merge normally, and confirm the merge-triggered Supabase workflow deploys the updated Edge Function. After deployment, rerun preflight `OPTIONS` checks for both dev origins and refresh the affected hosted pages. diff --git a/supabase/functions/_shared/cors.test.ts b/supabase/functions/_shared/cors.test.ts index 2b65e61..b7fe1ea 100644 --- a/supabase/functions/_shared/cors.test.ts +++ b/supabase/functions/_shared/cors.test.ts @@ -31,6 +31,8 @@ describe("edge CORS headers", () => { for (const origin of [ "https://tcoin-802yqav97-cubid-team.vercel.app", "https://tcoin-git-codex-preview-cubid-team.vercel.app", + "https://tcoin-dev-git-dev-cubid-team.vercel.app", + "https://tcoin-dev-802yqav97-cubid-team.vercel.app", "https://spare-change-802yqav97-cubid-team.vercel.app", "https://spare-change-git-codex-preview-cubid-team.vercel.app", ]) { @@ -45,12 +47,25 @@ describe("edge CORS headers", () => { } }); + it("allows the owned TCOIN dev custom domain", () => { + const headers = resolveCorsHeaders( + new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { + headers: { origin: "https://dev.tcoin.me" }, + }) + ); + + expect(headers["Access-Control-Allow-Origin"]).toBe("https://dev.tcoin.me"); + expect(headers.Vary).toBe("Origin"); + }); + it("does not reflect arbitrary origins", () => { for (const origin of [ "https://example.invalid", "https://tcoin-802yqav97-evil-team.vercel.app", "https://tcoin-802yqav97-cubid-team.vercel.app.evil.test", + "https://tcoin-dev-802yqav97-evil-team.vercel.app", "http://tcoin-802yqav97-cubid-team.vercel.app", + "http://dev.tcoin.me", ]) { const headers = resolveCorsHeaders( new Request("https://project.supabase.co/functions/v1/user-settings/bootstrap", { diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts index 66740e9..5198f44 100644 --- a/supabase/functions/_shared/cors.ts +++ b/supabase/functions/_shared/cors.ts @@ -5,11 +5,13 @@ const DEFAULT_ALLOWED_ORIGINS = [ "http://127.0.0.1:3001", "https://tcoin-git-dev-cubid-team.vercel.app", "https://tcoin-git-main-cubid-team.vercel.app", + "https://dev.tcoin.me", "https://tcoin.me", "https://www.tcoin.me", ]; -const VERCEL_PREVIEW_HOST_PATTERN = /^(?:tcoin|spare-change)(?:-[a-z0-9]+|-git-[a-z0-9-]+)-cubid-team\.vercel\.app$/; +const VERCEL_PREVIEW_HOST_PATTERN = + /^(?:tcoin|tcoin-dev|spare-change)(?:-[a-z0-9]+|-git-[a-z0-9-]+)-cubid-team\.vercel\.app$/; function readAllowedOriginsFromEnv(): string[] { const configured = [