diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d375dd6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## Merchant Or Buyer Outcome + +- Observed failure: +- Demand proof: +- Pricing hypothesis: +- First distribution format: + +## State Boundary + +- States or transitions changed: +- Authorization/consent boundary: +- Payment proof remains separate from fulfillment proof: +- Explicitly out of scope: + +## Verification + +- Commands and results: +- Failure fixture or regression: +- Security receipt or `N/A` with reason: + +## Delivery + +- Expected production health evidence: +- Human approvals still required: +- Public proof draft or `N/A` with reason: diff --git a/.github/workflows/delivery.yml b/.github/workflows/delivery.yml new file mode 100644 index 0000000..1b25996 --- /dev/null +++ b/.github/workflows/delivery.yml @@ -0,0 +1,69 @@ +name: Production delivery receipt + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + workflow_dispatch: + inputs: + expected_commit: + description: "Full main commit SHA; blank uses current main" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: sllr-production-delivery + cancel-in-progress: true + +jobs: + verify: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main') + runs-on: ubuntu-latest + timeout-minutes: 8 + env: + EXPECTED_COMMIT: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || inputs.expected_commit }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || 'main' }} + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "20" + + - name: Resolve expected commit + id: expected + run: | + expected="$EXPECTED_COMMIT" + if [[ -z "$expected" ]]; then + expected="$(git rev-parse HEAD)" + fi + if [[ ! "$expected" =~ ^[0-9a-f]{40}$ ]]; then + echo "expected_commit must be a full lowercase 40-character SHA" >&2 + exit 1 + fi + echo "value=$expected" >> "$GITHUB_OUTPUT" + + - name: Verify production revision and durable store + env: + EXPECTED_COMMIT_RESOLVED: ${{ steps.expected.outputs.value }} + run: | + node scripts/verify-sllr-delivery.mjs \ + --url https://sll-r.vercel.app \ + --expected-commit "$EXPECTED_COMMIT_RESOLVED" \ + --output "$RUNNER_TEMP/sllr-delivery-receipt.json" + + - name: Upload delivery receipt + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: sllr-delivery-${{ steps.expected.outputs.value }} + path: ${{ runner.temp }}/sllr-delivery-receipt.json + if-no-files-found: error + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index 7117e47..5816f8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,39 +1,76 @@ -# AGENTS.md - sll-r +# SLL-R Agent Contract -Repo rails for coding agents working in this project. +SLL-R is the merchant-side execution rail: -## Before Editing +```text +merchant-backed offer +-> exact quote +-> quote-bound consent +-> idempotent order +-> payment proof +-> fulfillment proof +-> canonical receipt +``` -- Confirm the current branch and dirty state with `git status --short --branch`. -- Read repo-local docs first: `README*`, package/config files, app entrypoints, and nearby tests. -- Preserve unrelated user changes. Do not revert, reformat, or clean files outside the task scope. -- Keep changes surgical. Prefer the existing stack and local patterns over new abstractions. +Payment proof is never fulfillment proof. Demo merchants are fixtures, not +partnership claims. -## Verification +## Required Brief + +Before editing, record: + +- merchant or buyer failure being solved +- acceptance criteria and failure fixture +- feature, repeated loop, or maintenance classification +- demand proof, pricing hypothesis, and first distribution format, or `N/A` +- affected state transitions and authorization boundary +- verification commands +- actions that remain human-controlled -Use the smallest meaningful check for the changed surface: +Ambiguous payment, merchant authority, consent, or receipt semantics block +implementation. -- JavaScript/TypeScript: prefer existing `check`, `test`, `lint`, `typecheck`, or `build` scripts from `package.json`. -- Swift/Xcode: identify project/workspace, scheme, simulator/device, deployment target, then run the narrow `xcodebuild` build/test command. -- Solidity/EVM: run the local Foundry/Hardhat tests relevant to touched contracts or scripts. -- Python: run targeted tests or `python -m py_compile` for touched scripts when no test suite exists. -- Docs/config only: run formatting/link/containment checks if the repo provides them; otherwise state why runtime verification is not applicable. +## Engineering Loop + +- Trigger: one scoped brief or GitHub issue. +- Durable state: issue/PR, tests, and the production delivery receipt. +- Input boundary: repo files plus redacted deterministic fixtures; no customer + messages, payment credentials, or production payload dumps. +- Maker: engineering agent on an isolated branch. +- Checker: `pnpm check`, dependency audit, review, and post-deploy health check. +- Feedback: a deterministic test, security review, or commit-aware production + check. +- Artifact: one atomic PR and one `sllr-delivery-receipt.json`. +- Convergence: tests pass and production reports the exact merged commit on a + durable store. +- Human approval: merge, Vercel settings/deploy changes, merchant onboarding, + production secrets, payment mode changes, cron activation, live transaction, + refund, and external messaging. +- Stop: unverifiable merchant data, missing consent, non-idempotent mutation, + payment/fulfillment conflation, tenant leak, production `memory` store, + revision mismatch, or three failed repair iterations. + +## Verification -Never claim verification passed if the command was not run. Report skipped checks with the exact reason. +Run: -## Boundaries +```bash +pnpm install --frozen-lockfile +pnpm check +pnpm audit --prod --audit-level high +``` -- Do not commit secrets, private keys, tokens, credentials, customer data, or local machine dumps. -- Do not deploy, publish, submit forms, send messages, merge PRs, change permissions, create credentials, or make financial/auth/security changes without explicit action-time approval. -- Do not delete meaningful local or cloud data unless explicitly asked for that exact action. -- For auth, payment, wallet, parser/import, dependency, or externally exposed changes, include a security review note or explain why it is not applicable. +For payment, auth, webhook, merchant mutation, or externally exposed changes, +include a focused security receipt. Raw scanner output is not a confirmed bug +until the reachable path is reproduced or regraded. -## Done Criteria +## Delivery Boundary -A handoff is complete only when it includes: +Vercel may deploy merged `main`. GitHub Actions does not initiate that deploy. +After CI succeeds, the delivery workflow polls only `GET /health` until it +observes the exact main commit on `supabase` or `redis_rest`, then uploads a +receipt. The checker must never create sessions, orders, payment attempts, +receipts, cron runs, or merchant mutations. -- changed files -- verification command and result -- skipped checks, if any -- risks or follow-up work -- commit/PR status, if shipping was requested +Never run a production deploy, activate live payments, send outreach, or merge +a PR without explicit user approval at action time. diff --git a/README.md b/README.md index 4867512..08c54a0 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,10 @@ Storage backend selection is Supabase → Redis REST/KV → memory: - **Redis REST/KV** supports Vercel KV and Upstash-compatible credentials. A durable backend is required for serverless or horizontally scaled deployment. -`GET /health` reports the selected store. Production must also set +`GET /health` reports the selected store and the deployment revision supplied by +Vercel, Railway, or another supported runtime. The post-CI delivery workflow +requires an exact main-commit match and rejects the in-memory store without +performing any commerce mutation. Production must also set `SLLR_MERCHANT_PAYMENT_VERIFY_SECRET`; see [env.example](./env.example). ## Run Locally diff --git a/docs/go-live-checklist.md b/docs/go-live-checklist.md index 3d6cbfc..ecb4308 100644 --- a/docs/go-live-checklist.md +++ b/docs/go-live-checklist.md @@ -27,6 +27,17 @@ Everything below is **configuration**, not code. --- +## Delivery receipt + +After main CI passes, `.github/workflows/delivery.yml` waits for +`https://sll-r.vercel.app/health` to report the exact merged commit and a +durable `supabase` or `redis_rest` store. It uploads +`sllr-delivery-receipt.json` and performs no buyer, merchant, cron, payment, or +receipt mutation. A missing revision or `memory` store is a failed delivery, +not a reason to weaken the check. + +--- + ## Phase 1 — SLL-R env (Vercel · TEST mode) Vercel → Project (sll-r) → Settings → Environment Variables (scope: Production). diff --git a/scripts/verify-sllr-delivery.mjs b/scripts/verify-sllr-delivery.mjs new file mode 100644 index 0000000..effb1d8 --- /dev/null +++ b/scripts/verify-sllr-delivery.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +import { writeFile } from "node:fs/promises"; + +function argument(name) { + const index = process.argv.indexOf(name); + return index === -1 ? null : process.argv[index + 1] ?? null; +} + +function positiveInteger(value, fallback) { + if (value === null) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`Expected a positive integer, got ${value}.`); + } + return parsed; +} + +const url = argument("--url"); +const expectedCommit = argument("--expected-commit"); +const output = argument("--output"); +const attempts = positiveInteger(argument("--attempts"), 20); +const intervalMs = positiveInteger(argument("--interval-ms"), 15_000); + +if (!url || !expectedCommit || !output) { + throw new Error("--url, --expected-commit, and --output are required."); +} +if (!/^https:\/\/[a-z0-9.-]+(?::\d+)?(?:\/.*)?$/i.test(url) + && !/^http:\/\/127\.0\.0\.1:\d+(?:\/.*)?$/.test(url)) { + throw new Error("Delivery verification requires HTTPS or loopback HTTP."); +} +if (!/^[0-9a-f]{40}$/.test(expectedCommit)) { + throw new Error("Expected commit must be a full lowercase 40-character SHA."); +} + +const healthUrl = new URL("/health", url).toString(); +const durableStores = new Set(["supabase", "redis_rest"]); +let lastObservation = null; + +for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetch(healthUrl, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + const payload = await response.json(); + lastObservation = { + status: response.status, + ok: payload?.ok === true, + product: payload?.product ?? null, + store: payload?.store ?? null, + revision: payload?.revision ?? null, + }; + + if ( + response.ok + && lastObservation.ok + && lastObservation.product === "SLL-R" + && durableStores.has(lastObservation.store) + && lastObservation.revision === expectedCommit + ) { + const receipt = { + contractVersion: "sllr-delivery-receipt/v1", + product: "SLL-R", + expectedCommit, + observedCommit: lastObservation.revision, + productionUrl: url, + store: lastObservation.store, + attempts: attempt, + checks: { + serviceIdentity: "passed", + exactRevision: "passed", + durableStore: "passed", + paymentMutation: "not-performed", + merchantMutation: "not-performed", + }, + status: "verified", + }; + await writeFile(output, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"); + console.log(`SLL-R delivery verified at ${expectedCommit}.`); + process.exit(0); + } + } catch (error) { + lastObservation = { + error: error instanceof Error ? error.message : String(error), + }; + } + + console.log( + `Attempt ${attempt}/${attempts}: production has not converged to ${expectedCommit}.`, + ); + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +throw new Error( + `SLL-R production delivery did not converge: ${JSON.stringify(lastObservation)}`, +); diff --git a/src/scripts/smoke.ts b/src/scripts/smoke.ts index 036f31e..64f0ff0 100644 --- a/src/scripts/smoke.ts +++ b/src/scripts/smoke.ts @@ -2943,6 +2943,9 @@ async function smokePersonalShop(origin: string) { async function main() { smokeMoneyBoundaries(); + const previousRevision = process.env.VERCEL_GIT_COMMIT_SHA; + const smokeRevision = "0123456789abcdef0123456789abcdef01234567"; + process.env.VERCEL_GIT_COMMIT_SHA = smokeRevision; const server = createSllrServer(); server.listen(0); await once(server, "listening"); @@ -2951,6 +2954,21 @@ async function main() { const origin = `http://127.0.0.1:${address.port}`; try { + const health = await getJson(origin, "/health") as { + ok?: boolean; + product?: string; + store?: string; + revision?: string | null; + }; + if ( + health.ok !== true + || health.product !== "SLL-R" + || health.store !== "memory" + || health.revision !== smokeRevision + ) { + throw new Error(`Health did not expose delivery identity: ${JSON.stringify(health)}`); + } + const world = await getText(origin, "/world"); if ( !world.response.headers.get("content-type")?.includes("text/html") @@ -3922,6 +3940,11 @@ async function main() { console.log("SLL-R smoke passed"); } finally { + if (previousRevision === undefined) { + delete process.env.VERCEL_GIT_COMMIT_SHA; + } else { + process.env.VERCEL_GIT_COMMIT_SHA = previousRevision; + } server.close(); } } diff --git a/src/server.ts b/src/server.ts index edc7137..20e54a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -52,6 +52,16 @@ function json(response: ServerResponse, status: number, payload: unknown) { response.end(JSON.stringify(payload, null, 2)); } +function deploymentRevision() { + const candidates = [ + process.env.VERCEL_GIT_COMMIT_SHA, + process.env.RAILWAY_GIT_COMMIT_SHA, + process.env.SOURCE_VERSION, + process.env.GIT_COMMIT_SHA, + ]; + return candidates.find((candidate) => candidate && /^[0-9a-f]{40}$/.test(candidate)) ?? null; +} + function html(response: ServerResponse, status: number, payload: string) { response.writeHead(status, { "content-type": "text/html; charset=utf-8" }); response.end(payload); @@ -364,7 +374,12 @@ export async function handleSllrRequest(request: IncomingMessage, response: Serv return json(response, 200, rootDiscovery(originFrom(request))); } if (request.method === "GET" && url.pathname === "/health") { - return json(response, 200, { ok: true, product: "SLL-R", store: storeBackendName() }); + return json(response, 200, { + ok: true, + product: "SLL-R", + store: storeBackendName(), + revision: deploymentRevision(), + }); } if (request.method === "GET" && url.pathname === "/sllr-logo.svg") { return svg(response, 200, ``);