diff --git a/.env.example b/.env.example index cfa4f8e4..0ea8a244 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ DEMO_BOARD_SLUG= # everyone; set it in the host env, never commit a value here. Unset = # the pages link to GitHub issues for non-private questions. LEGAL_CONTACT_EMAIL= +# PIN-263: judging-week landing links (https only; unset = hidden) +DEMO_VIDEO_URL= +PROMO_POST_URL= # Board Plus billing (PIN-103/PIN-149, docs/15-board-plus.md): off (default) # answers every checkout "unavailable" and writes nothing; mock grants # Plus instantly for demos (labelled source "mock", no money moves). diff --git a/.github/workflows/report-sweep.yml b/.github/workflows/report-sweep.yml new file mode 100644 index 00000000..b4dcc1dc --- /dev/null +++ b/.github/workflows/report-sweep.yml @@ -0,0 +1,119 @@ +# PIN-272 report backstop: the timed takedown of ADR-004. +# +# Whatever a report hid is tombstoned by itself once Policy.takedownHours +# have passed with no operator decision (PIN-278; docs/20, +# docs/adr/004-report-and-hold.md). The app has no scheduler, so this +# job runs `report:ops sweep` every six hours; a report waits at most +# one run past its deadline. Content reported as illegal is taken down +# too, with its Storage holds kept as evidence. +# +# PUBLIC REPO: anyone can read this job's log. The sweep prints counts +# only (due / takenDown / skipped), never a report id, reference, slug +# or link. +# +# Environments: the job reuses reconcile-dev / reconcile-prod (same +# selection rule as billing-reconciliation.yml: dispatch input, else the +# repository variable PINNED_RECONCILE_ENV). It needs its OWN identity +# because the reconciliation service account is read-only; until the +# variables below exist the run skips with a warning. +# +# WHAT THE OPERATOR MUST ADD (never invent values; never paste them +# into issues, PRs or logs). In EACH environment: +# Variable PINNED_REPORT_SWEEP_WIF_PROVIDER +# the same provider resource name as PINNED_RECONCILE_WIF_PROVIDER +# Variable PINNED_REPORT_SWEEP_SERVICE_ACCOUNT +# @.iam.gserviceaccount.com +# Google Cloud, once per project, from an operator machine (placeholders in <>): +# P= N= POOL= SA=pinned-report-sweep BUCKET=

.firebasestorage.app +# gcloud iam service-accounts create "$SA" --project "$P" --display-name "Report sweep (timed takedown)" +# gcloud projects add-iam-policy-binding "$P" --member "serviceAccount:$SA@$P.iam.gserviceaccount.com" --role roles/datastore.user --condition=None +# gcloud storage buckets add-iam-policy-binding "gs://$BUCKET" --member "serviceAccount:$SA@$P.iam.gserviceaccount.com" --role roles/storage.objectAdmin +# gcloud iam service-accounts add-iam-policy-binding "$SA@$P.iam.gserviceaccount.com" --project "$P" --role roles/iam.workloadIdentityUser \ +# --member "principalSet://iam.googleapis.com/projects/$N/locations/global/workloadIdentityPools/$POOL/attribute.repository/MoriaCrew/Pinned" +# The existing pool and provider (billing-reconciliation.yml step 3) are +# reused: their attribute condition already binds the environment name. + +name: Report sweep + +on: + schedule: + - cron: "17 */6 * * *" # every 6 h at :17 UTC, from the default branch (main) + workflow_dispatch: + inputs: + environment: + description: "Target environment (runs in reconcile-dev or reconcile-prod)" + type: choice + options: [dev, prod] + default: dev + +permissions: + contents: read + +jobs: + sweep: + runs-on: ubuntu-latest + environment: ${{ (inputs.environment || vars.PINNED_RECONCILE_ENV) == 'prod' && 'reconcile-prod' || 'reconcile-dev' }} + permissions: + contents: read + id-token: write # Workload Identity Federation token; no key in a secret + env: + SWEEP_ENV: ${{ (inputs.environment || vars.PINNED_RECONCILE_ENV) == 'prod' && 'prod' || 'dev' }} + steps: + - name: Guard — skip until the environment is configured + id: guard + env: + HAS_WIF_PROVIDER: ${{ vars.PINNED_REPORT_SWEEP_WIF_PROVIDER != '' }} + HAS_SERVICE_ACCOUNT: ${{ vars.PINNED_REPORT_SWEEP_SERVICE_ACCOUNT != '' }} + run: | + if [ "$HAS_WIF_PROVIDER" = "true" ] && [ "$HAS_SERVICE_ACCOUNT" = "true" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Report sweep skipped::Environment reconcile-$SWEEP_ENV needs variables PINNED_REPORT_SWEEP_WIF_PROVIDER and PINNED_REPORT_SWEEP_SERVICE_ACCOUNT (see the header of this workflow) to enable." + { + echo "## Report sweep ($SWEEP_ENV) — skipped (PIN-272)" + echo "Environment \`reconcile-$SWEEP_ENV\` has no sweep identity yet. See the header of \`.github/workflows/report-sweep.yml\`." + } >> "$GITHUB_STEP_SUMMARY" + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.guard.outputs.enabled == 'true' + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + if: steps.guard.outputs.enabled == 'true' + with: + node-version: "22" + package-manager-cache: false + + # Before the auth step and without lifecycle scripts, so no + # dependency install code runs while a Google credential exists. + - name: Install (no lifecycle scripts) + if: steps.guard.outputs.enabled == 'true' + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Authenticate to Google Cloud (Workload Identity Federation) + if: steps.guard.outputs.enabled == 'true' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.PINNED_REPORT_SWEEP_WIF_PROVIDER }} + service_account: ${{ vars.PINNED_REPORT_SWEEP_SERVICE_ACCOUNT }} + + - name: Sweep due reports + if: steps.guard.outputs.enabled == 'true' + run: | + set +e + if [ "$SWEEP_ENV" = "prod" ]; then + npm run -s report:ops -- --project moriacrew-pinned-prod sweep > sweep.out + else + npm run -s report:ops -- --project moriacrew-pinned-dev sweep > sweep.out + fi + code=$? + { + echo "## Report sweep ($SWEEP_ENV) — exit $code" + echo '```' + grep '^REPORT_SWEEP ' sweep.out || echo "No summary line: see the job log." + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$code" diff --git a/.gitignore b/.gitignore index 178706cd..70e2a4b4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ coverage/ .DS_Store .spike-work/ +.showcase-work/ +.ops-build/ diff --git a/AGENTS.md b/AGENTS.md index d907c0b1..310dc88c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ dropped (Oct 28); its docs are in `docs/archive/` — do not apply them. written dismissal. Medium+ security findings block merge. **Never merge to `main`** without Robin; promotion is a separate promotion PR into `main` (squash only, per the `Protect main` - ruleset) after a hosted dev smoke (`docs/DEPLOY.md` §4). + ruleset) after a hosted dev smoke (`docs/DEPLOY.md` §5). 3. **AI behind the gateway.** All model calls go through the six capability ports in `src/gateway/index.ts` (`ElementNamer`, `Segmenter`, `SceneComposer`, `ChipSuggester`, `TapNamer`, diff --git a/README.md b/README.md index c50b408e..da3679bd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Pinneds — your stuff, pinned together +# Pinneds: your stuff, pinned together Friends drop photos onto one shared board. Meta's models, called through the Meta Model API, cut out the things in them, and anyone can mix everyone's pins into new scenes that @@ -52,7 +52,7 @@ and evidence for each: [`docs/19-meta-models.md`](docs/19-meta-models.md). | Model (ID) | What it does in Pinneds | Port | | --- | --- | --- | -| **Muse Spark** (`muse-spark-1.3`) | Names 1–6 things per photo as short noun phrases SAM can cut, and flags faces (`elements.v1`). Names a cut-out you tapped, from its crop (`tap-name.v1`). Suggests scene ideas for the picked pins in the viewer's UI language, cached 24 h per language (`chips.v2`). | `ElementNamer`, `TapNamer`, `ChipSuggester` | +| **Muse Spark** (`muse-spark-1.3`) | Names 1–6 things per photo as short noun phrases SAM 3.1 can cut, and flags faces (`elements.v1`). Names a cut-out you tapped, from its crop (`tap-name.v1`). Suggests scene ideas for the picked pins in the viewer's UI language, cached 24 h per language (`chips.v2`). | `ElementNamer`, `TapNamer`, `ChipSuggester` | | **SAM 3.1** (`sam-3.1`) | Cuts out each named thing (one concept per call, four calls at a time) and whatever you tap (point prompt), keeping the most confident usable mask. | `Segmenter` | | **Muse Image** (`muse-image-1.0`) | Composes 1–4 pins into one new scene through `/v1/responses` (`scene.v1`, `store: false`); a remix sends the parent scene first. It also made the source photos of the Meta-made landing sets. | `SceneComposer` | | **Muse Voice Transcribe** (`muse-voice-transcribe-1.0`) | Turns a spoken scene idea (up to 30 s) into text. The audio is never stored. | `SpeechTranscriber` | @@ -73,7 +73,7 @@ flowchart LR point --> tapname["Muse Spark
names the tapped pin"] tapname --> pins pins --> chips["Muse Spark
idea chips"] - voice["Spoken idea"] --> asr["Muse Voice
transcribes"] + voice["Spoken idea"] --> asr["Muse Voice Transcribe
transcribes"] chips --> idea["Scene idea"] asr --> idea pins --> compose["Muse Image
composes"] @@ -110,17 +110,32 @@ Spec: [`docs/11-studio.md`](docs/11-studio.md) §5. Decisions: capability slugs, validated before any data path is built. - **Privacy.** - Original photos are never stored. An upload is checked, stripped of EXIF and - re-encoded into a working copy under `tmp/`. Only cut-outs (`elements/`) and scenes - (`scenes/`) persist, served through routes that check the board link; client - Firestore and Storage rules deny everything. - - The working copy is deleted when the uploader confirms their pins ("Pin N"). Any copy - older than Policy `workingCopyTtlHours` (24 h) is swept the next time someone adds a - photo to that board, and a bucket lifecycle rule in `storage.lifecycle.json` deletes - leftover working copies as the backstop. + re-encoded into a working copy; only cut-outs and scenes persist, served through + routes that check the board link. Client Firestore and Storage rules deny everything. + - The working copy is deleted when the uploader confirms their pins. If they never do, + the next photo added to that board sweeps any copy older than Policy + `workingCopyTtlHours` (24 h), and the bucket lifecycle rule in `storage.lifecycle.json` + is the backstop. - Calls to Meta run in Standard mode: not used for training, which is not the same as not stored. The Muse Image compose call sends `store: false`. We make no "zero data retention" claim. +```mermaid +flowchart LR + up["Upload
(original never written)"] --> chk["Checked, EXIF stripped,
re-encoded"] + chk --> wc["Working copy
tmp/"] + wc --> spark["Muse Spark
names"] + wc --> sam["SAM 3.1
cuts out"] + sam --> el["Cut-outs
elements/"] + el --> sc["Scenes
scenes/"] + wc -->|"pins confirmed"| del["Working copy deleted"] + wc -.->|"older than 24 h: swept"| del + wc -.->|"1-day lifecycle rule"| del +``` + +*What persists: cut-outs and scenes. The original is never written; the working copy is +temporary.* + ## Run it locally ### Offline, with mock models (no key) @@ -160,13 +175,14 @@ npm test npm run build ``` -On `dev` at `a90cd7b` (Nov 2, 2026), `npm test` reports **1,879 tests passed, 3 -skipped**, in 165 test files. CI (`.github/workflows/ci.yml`) runs all three with -`AI_MOCK=1`, so it never makes a paid model call. Gitleaks scans the full history on every -PR (`.github/workflows/secret-scan.yml`) and runs as a pre-commit hook, and Cursor Bugbot -reviews every PR: as of Nov 2, each of the 215 merged PRs passed CI and gitleaks and got a -Bugbot review ([`docs/build-log.md`](docs/build-log.md)). Domain and Policy logic have -unit tests; the gateway has contract tests that replay recorded spike fixtures +On `dev` at `d958f29` (Nov 4, 2026), `npm test` reports **2,091 tests passed, 3 +skipped**, in 181 test files (one of them skipped). CI (`.github/workflows/ci.yml`) runs +all three with `AI_MOCK=1`, so it never makes a paid model call. Gitleaks scans the full +history on every PR (`.github/workflows/secret-scan.yml`) and runs as a pre-commit hook, +and Cursor Bugbot reviews every PR: as of Nov 4, each of the 240 merged PRs passed CI and +gitleaks and got a Bugbot review ([`docs/build-log.md`](docs/build-log.md)). Domain and +Policy logic have unit tests; the gateway has contract tests that replay recorded spike +fixtures (`eval/fixtures/studio/`). Live-model runs happen by hand, with cost logged; results are in [`eval/studio-findings.md`](eval/studio-findings.md). @@ -174,34 +190,30 @@ unit tests; the gateway has contract tests that replay recorded spike fixtures `PLUS_MODE` is `off` (the default: nothing is sold), `mock` (a no-money demo) or `stripe`. With `stripe` the app sells one pack, **Board Plus (€2.99, Stripe)**: once per board, it -unlocks every board look and foil stickers and adds 20 creations (AI makes). Any member +unlocks every board look and foil stickers and adds 20 creations. Any member can buy it for the board; guests never have to pay. Creations are a Policy switch -(`credits.enabled`, off in code): when on, a maker's first 3 boards a week start with 5 -free (later ones start at 0), each AI scene uses one (photos and cut-outs never do), and a -collage is free. A board without Plus cuts up to 15 free photos; Plus removes that limit. Purchases are granted by Stripe webhook only, and +(`credits.enabled`: off in the code defaults, on in both hosted backends through +`POLICY_OVERRIDES_JSON`). When on, a maker's first 3 boards a week start with 5 free +creations (prod gives 20 during judging week; later boards start at 0), each AI scene uses +one (photos and cut-outs never do), and a collage is free. A board without Plus cuts up to +15 free photos; Plus removes that limit. Purchases are granted by Stripe webhook only, and keys come from Secret Manager, never git. Design and runbook: [`docs/15-board-plus.md`](docs/15-board-plus.md). ## Docs -- [`docs/README.md`](docs/README.md): index of every doc +- [`docs/README.md`](docs/README.md): index of every doc, in reading order - [`docs/19-meta-models.md`](docs/19-meta-models.md): each Meta model, prompt, cost and fallback -- [`docs/build-log.md`](docs/build-log.md): who built what, PR by PR - [`docs/11-studio.md`](docs/11-studio.md): product and architecture spec - [`docs/adr/003-studio-architecture.md`](docs/adr/003-studio-architecture.md): Policy, ports, jobs, remix -- [`docs/DEPLOY.md`](docs/DEPLOY.md): deploy runbook and env matrix -- [`docs/13-auth.md`](docs/13-auth.md): optional Google sign-in -- [`docs/15-board-plus.md`](docs/15-board-plus.md): Board Plus and creations -- [`docs/16-ai-usage.md`](docs/16-ai-usage.md): AI call log and cost estimates -- [`docs/17-showcase-sets.md`](docs/17-showcase-sets.md): how the landing sets are made -- [`docs/18-i18n.md`](docs/18-i18n.md): languages and translation -- [`SECURITY.md`](SECURITY.md) and [`AGENTS.md`](AGENTS.md): public-repo guardrails and agent rules +- [`docs/build-log.md`](docs/build-log.md): who built what, PR by PR +- [`SECURITY.md`](SECURITY.md): public-repo guardrails ## Known limits -- **Cuts can miss.** Sometimes SAM finds no usable mask for a named thing, and it doesn't - become a pin; tapping it in the photo is the workaround. Spark names the same photo a - little differently from run to run. +- **Cuts can miss.** Sometimes SAM 3.1 finds no usable mask for a named thing, and it does not + become a pin; tapping it in the photo is the workaround. Muse Spark names the same photo + a little differently from run to run. - **Costs are estimates** from Meta list prices (`src/gateway/costs.ts`). The provider dashboard is authoritative. - **Not every picture is from Meta.** The classic landing set, the `/new` look-picker @@ -209,8 +221,7 @@ keys come from Secret Manager, never git. Design and runbook: photos are Higgsfield images (SAM 3.1 cut out the stickers). The try-on and cat landing sets are Meta end to end. - **AI-written text is English in places.** Pin names and some scene text stay English - whatever the UI language (PIN-200). Generated idea chips follow the UI language - (PIN-214); the fixed starter chips stay English until their translations land. + whatever the UI language. Idea chips, generated and fixed, follow the UI language. - **Machine translations.** Muse Spark Open wrote and reviewed them; no native speaker has checked them yet. Latin-script languages only. @@ -224,7 +235,7 @@ Which agent built what, with PR numbers and dates: [`docs/build-log.md`](docs/bu ## License -Copyright (C) 2026 Moria Crew. AGPL-3.0 — see `LICENSE`. In short: you may use, study and modify this +Copyright (C) 2026 Moria Crew. AGPL-3.0, see `LICENSE`. In short: you may use, study and modify this code, but if you offer a modified version to others over a network you must release your source under the same licence. Hackathon note: the organisers separately receive a broad licence to submitted materials diff --git a/SECURITY.md b/SECURITY.md index f3ed76f0..507c779b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,4 @@ -# Security — public-repo guardrails for Pinned +# Security: public-repo guardrails This repository is **public for its entire life**. Anything committed is world-readable forever, including in git history after deletion. These @@ -16,13 +16,13 @@ photo. They apply to the human operator and to coding agents alike. Setup per clone: `pre-commit install` (see `AGENTS.md`). Never bypass a gate with `--no-verify`, force-push, or an allowlist edit in the same PR -as the code it unblocks — allowlist changes need their own reviewed PR. +as the code it unblocks. Allowlist changes need their own reviewed PR. ## Never commit (any branch, any file, any commit message) -- API keys, tokens, passwords, session secrets (Meta, Baseten, Modal, - Fireworks, Stripe secret keys and the webhook signing secret, - Firebase, DB, storage) — values live only in local `.env`, host +- API keys, tokens, passwords, session secrets (Meta, Baseten, Stripe + secret keys and the webhook signing secret, Firebase, DB, storage + and any other provider key). Values live only in local `.env`, host env, Secret Manager, GitHub Actions secrets. `.env.example` holds names with empty values. - Private keys/certs (`*.pem`, `*.key`, `*.p12`), credential JSON, @@ -31,22 +31,24 @@ as the code it unblocks — allowlist changes need their own reviewed PR. photos together, emails, chat screenshots with identities. Test assets in `eval/` must be consented samples, clearly synthetic where possible. - Full request/response dumps from live AI calls containing user content. - Log shapes, counts, latency and cost — redact payloads (`--redact`). + Log shapes, counts, latency and cost; redact payloads (`--redact`). -## Rate-limit client address (PIN-112) +## Rate-limit client address Per-IP limits key on one address from `X-Forwarded-For`. Entries a client sends come first; the hosting platform appends its own after them, so only the platform-appended client entry can be trusted. `CLIENT_IP_TRUSTED_HOPS=n` makes the key the entry `n` positions from -the right end; unset keeps the first entry (local default). A hosted -backend sets `n` only after measuring its layout with the lab probe -`GET /api/lab/client-ip` (dev only; it answers header counts and -positions, never an address — usage in the route comment). -`CLIENT_IP_HEADER` (a platform header read first) stays unset unless -the probe shows the platform overwrites a client-sent copy. +the right end (too few entries fall back to the first); unset keeps +the first entry (local default). Each hosted backend sets it in its App +Hosting config (`apphosting*.yaml`) after measuring its layout with the +lab probe `GET /api/lab/client-ip` (dev and local only; it answers +header counts and positions, never an address). +`CLIENT_IP_HEADER` (a platform header read first, used only when it +holds a single value) stays unset unless the probe shows the platform +overwrites a client-sent copy. -## If something leaks anyway — rotate first +## If something leaks anyway: rotate first 1. **Revoke/rotate the credential immediately.** Deleting the file or rewriting history does not un-leak a key; rotation does. @@ -54,33 +56,34 @@ the probe shows the platform overwrites a client-sent copy. 3. Then clean up: remove the secret, and only if it was a credential that *cannot* be rotated (personal data), purge history and ask GitHub Support to drop cached views. Personal photos: treat as - incident — inform the person, purge, document in Linear. + incident: inform the person, purge, document in Linear. 4. Record what happened (no secret values) in the relevant Linear issue. -## Repository settings inventory (PIN-20) +## Repository settings inventory Settings live in GitHub (not in git); this inventory is the record. Verify anytime with `gh api repos/MoriaCrew/Pinned/rulesets` and `gh api repos/MoriaCrew/Pinned/rules/branches/main`. -- **Ruleset `Protect main`** (active, default branch): PR required - (0 approvals — solo; review threads must resolve; squash only), +- **Ruleset `Protect main`** (active, on `main` and `dev`): PR required + (0 approvals, solo; review threads must resolve; squash only), required checks `check`, `gitleaks (full history)`, `Cursor Bugbot` (branches need not be strictly up-to-date), linear history, no force-push, no branch deletion. Repository Admin bypass is *pull-requests-only*: even admins (and agents using admin tokens) cannot push directly, but may bypass at PR merge as break-glass. -- **Merges:** squash only; delete branch on merge; auto-merge and - update-branch enabled. Wiki and Discussions off. +- **Merges:** squash only; auto-merge and update-branch enabled; + branches are not deleted on merge (stacked PRs are retargeted by + hand). Wiki and Discussions off. - **Actions:** default `GITHUB_TOKEN` permissions read-only; workflows cannot approve PR reviews. - **Security features:** secret scanning + push protection + non-provider patterns + validity checks + AI detection on; Dependabot alerts + security updates on; private vulnerability reporting on. Forking stays allowed (public open-source repo); - the AGPL — not settings — governs reuse. + the AGPL, not settings, governs reuse. ## Reporting Found a secret or personal data in this repo? Contact the owner via -GitHub (@Robin-ELM) — please do not open a public issue quoting it. +GitHub (@Robin-ELM). Please do not open a public issue quoting it. diff --git a/apphosting.dev.yaml b/apphosting.dev.yaml index 29b0359a..c03137b4 100644 --- a/apphosting.dev.yaml +++ b/apphosting.dev.yaml @@ -30,6 +30,13 @@ env: # address is printed on both pages, so it is not a secret. - variable: LEGAL_CONTACT_EMAIL value: contact@moriacrew.com + # PIN-263: judging-week links on the landing, public plain values. + # Unset a value to take its link down (no code change). + - variable: DEMO_VIDEO_URL + value: https://youtu.be/q6lkrA6fsCI + # A test post until the launch post is published. + - variable: PROMO_POST_URL + value: https://x.com/MoriaCrew/status/2099512136368071033 - variable: DATA_BACKEND value: firestore - variable: FIREBASE_PROJECT_ID diff --git a/apphosting.prod.yaml b/apphosting.prod.yaml index 4edeef53..ef005df9 100644 --- a/apphosting.prod.yaml +++ b/apphosting.prod.yaml @@ -52,6 +52,11 @@ env: # address is printed on both pages, so it is not a secret. - variable: LEGAL_CONTACT_EMAIL value: contact@moriacrew.com + # PIN-263: judging-week links on the landing, public plain values. + # Unset a value to take its link down (no code change). + - variable: DEMO_VIDEO_URL + value: https://youtu.be/q6lkrA6fsCI + # PROMO_POST_URL: add the launch post's link here once it is published. - variable: DATA_BACKEND value: firestore - variable: FIREBASE_PROJECT_ID diff --git a/docs/03-ai-sdlc.md b/docs/03-ai-sdlc.md index 5e202060..d64d46ab 100644 --- a/docs/03-ai-sdlc.md +++ b/docs/03-ai-sdlc.md @@ -1,4 +1,6 @@ -# Pinneds — AI SDLC: Linear → agent → PR → `dev` → `main` +# 03. AI SDLC: Linear → agent → PR → `dev` → `main` + +_Internal process note: how the developer and the coding agents organise the work. The README and the build log cite it for context; it is not needed to use the app. Start at the [docs index](README.md)._ _One human (Robin) steers; coding agents (Claude Code, Muse Code, Cursor) execute one Linear issue at a time. If a step slows delivery diff --git a/docs/06-photo-intake.md b/docs/06-photo-intake.md index 739e567c..df478a47 100644 --- a/docs/06-photo-intake.md +++ b/docs/06-photo-intake.md @@ -1,4 +1,4 @@ -# 06 — Photo intake and ephemeral storage (PIN-34) +# 06. Photo intake and ephemeral storage ## Invariants diff --git a/docs/07-sam-lab.md b/docs/07-sam-lab.md index 5c52ddd7..35283290 100644 --- a/docs/07-sam-lab.md +++ b/docs/07-sam-lab.md @@ -1,4 +1,4 @@ -# 07 — SAM lab (PIN-36) +# 07. SAM lab Dev-only tool at `/lab/sam` (enabled by `PINNED_LAB=1` on dev, or locally in memory mode; 404 elsewhere). Built to learn what SAM 3.1 diff --git a/docs/10-custom-domains.md b/docs/10-custom-domains.md index f84fe2a7..5af1d246 100644 --- a/docs/10-custom-domains.md +++ b/docs/10-custom-domains.md @@ -1,4 +1,4 @@ -# 10 — Custom domains (pinneds.com) +# 10. Custom domains (pinneds.com) Status: Firebase domains created 2026-10-28; DNS applied at GoDaddy the same day. Ownership is ACTIVE for apex + dev; diff --git a/docs/11-studio.md b/docs/11-studio.md index 5ca21834..900bac70 100644 --- a/docs/11-studio.md +++ b/docs/11-studio.md @@ -1,4 +1,4 @@ -# 11 — Pinneds Studio (PIN-49) +# 11. Pinneds Studio _Status: source of truth for the Studio pivot from Oct 28, 2026. Built from spec sections 1–8 of the Oct 28 hand-off (PIN-48), @@ -162,7 +162,8 @@ object. The defaults below are proposals; tune them from evidence. | Models | `muse-spark-1.3`, `sam-3.1`, `muse-image-1.0`, `muse-voice-transcribe-1.0` | | Prompt versions | `elements.v1`, `scene.v1`, `tap-name.v1`, `chips.v2` | | `credits.enabled` / `credits.starterMakes` | `false` / 5 (PIN-151: the makes pool, docs/15; 25 before PIN-181; off = pre-credit behaviour exactly, on = 1 make per successful photo or AI scene, collage stays free) | -| `demo.ttlHours` / `demo.starterMakes` / `demo.freePhotos` / `demo.maxPerIp` / `demo.maxTotal` / `demo.windowMs` / `demo.sweepBatch` | 24 / 2 / 3 / 3 / 200 / 86,400,000 / 5 (PIN-251: per-visitor demo boards cloned from the landing template, `docs/DEPLOY.md` §6.3; the caps bound a creation path outside the per-maker rule, worst case about `maxTotal` x `starterMakes` scenes per window) | +| `demo.ttlHours` / `demo.starterMakes` / `demo.freePhotos` / `demo.maxPerIp` / `demo.maxTotal` / `demo.windowMs` / `demo.sweepBatch` | 24 / 2 / 3 / 3 / 200 / 86,400,000 / 5 (PIN-251: per-visitor demo boards cloned from the landing template, `docs/DEPLOY.md` §6 step 3; the caps bound a creation path outside the per-maker rule, worst case about `maxTotal` x `starterMakes` scenes per window) | +| `report.perIp` / `report.maxBytes` / `report.messageMaxChars` / `report.sweepBatch` | 5 per 600,000 ms / 4,096 / 1,000 / 20 (PIN-266: the in-app report route and its backstop, `docs/20-abuse-runbook.md`, ADR-004; the takedown deadline is `takedownHours`) | > **A2 (budget), as of Oct 31:** the original hosted $2 / 200 calls > was too small for family play, so it was raised. Today the code @@ -204,8 +205,24 @@ object. The defaults below are proposals; tune them from evidence. ### 5.3 Jobs -- `DecomposeJob` and `SceneJob` documents move - `pending → running → ready / failed / refused`. +- `DecomposeJob` and `SceneJob` documents move through five states; + `ready`, `failed` and `refused` are terminal. + +```mermaid +stateDiagram-v2 + [*] --> pending + pending --> running: claim before call + pending --> failed: stale sweep + running --> ready + running --> failed: error or stale sweep + running --> refused + ready --> [*] + failed --> [*] + refused --> [*] +``` + +_Job states for `DecomposeJob` and `SceneJob`; only one caller wins the claim. The stale sweep applies to `DecomposeJob` only._ + - They are idempotent by job id, with claim-before-call, as in `src/server/decompose-job.ts`. - The request does the work and writes the status; the client polls. @@ -250,9 +267,11 @@ object. The defaults below are proposals; tune them from evidence. > are chosen or at TTL, and (b) a 1-day lifecycle rule as backstop. > (a) shipped: the app deletes the working copy at "Pin selected" and > an opportunistic per-board sweep deletes copies past the TTL -> (`src/server/board-api.ts`, `decompose-job.ts`). (b) lands with -> PIN-209. Until the 1-day rule is applied on a bucket, do not claim -> a hard 24 h deletion in user-facing copy. +> (`src/server/board-api.ts`, `decompose-job.ts`). (b) shipped: +> `storage.lifecycle.json` now holds the 1-day rule on `tmp/` (PIN-209, +> Oct 31). `firebase deploy` does not apply it; the operator applies +> it to each bucket (`docs/DEPLOY.md` §6 step 2). Claim a 24 h +> deletion in user-facing copy only for a bucket that has the rule. ### 5.6 Scene inputs @@ -433,7 +452,8 @@ owning issue in brackets decides or verifies. **Default:** app deletes the working copy when pins are chosen (or at 24 h via a sweeper); operator tightens the lifecycle rule to 1 day as backstop. No 24 h claim in copy until both exist. - **Status:** the app side shipped; the 1-day rule is PIN-209 (A6). + **Status:** both shipped (A6); the operator applies the 1-day rule + per bucket (`docs/DEPLOY.md` §6 step 2). [PIN-53 + operator] 6. **Slug validation:** a `%2F` slug reaches Firestore as a path. Validate at every route entry, or centrally in the repository? @@ -442,17 +462,17 @@ owning issue in brackets decides or verifies. not 500), plus a unit test. Exactly 22 chars matches the current 128-bit base64url generator. [PIN-51] 7. **Preset fate:** `docs/archive/09` presets bundle a host persona with a - look. The game is gone — keep, halve, or delete? + look. The game is gone: keep, halve, or delete? **Default (A5):** looks become board themes (tokens + PIN-41 art); personas are deleted in PIN-52. [PIN-52 / PIN-53] -8. **Demo-board seeding:** how is the judge demo board populated — +8. **Demo-board seeding:** how is the judge demo board populated: synthetic pins committed as fixtures, generated live on first visit, or seeded by a script against prod? **Default:** synthetic elements + scenes committed as small fixtures under `eval/` rules, seeded by an idempotent script per environment; no live paid calls on judge traffic. [PIN-55] 9. **Faces copy:** `faces: note` shows "everyone in this photo OK - with being remixed?" — dismissible per photo, per board, or + with being remixed?", dismissible per photo, per board, or once per participant? **Default:** once per photo containing a face; never blocking (§2). Refusals from the provider render as a friendly `refused` @@ -498,9 +518,9 @@ owning issue in brackets decides or verifies. | Issue | What | Blocked by | Executor | | --- | --- | --- | --- | -| PIN-48 | Parent: Studio pivot hand-off | — | Muse Code | -| PIN-49 | Spec into the repo (`docs/11`, ADR-003), agent rules rewritten | — | Muse Code | -| PIN-50 | Slice 0: live spikes on synthetic images (§8) | — | Muse Code | +| PIN-48 | Parent: Studio pivot hand-off | none | Muse Code | +| PIN-49 | Spec into the repo (`docs/11`, ADR-003), agent rules rewritten | none | Muse Code | +| PIN-50 | Slice 0: live spikes on synthetic images (§8) | none | Muse Code | | PIN-51 | Policy and gateway foundations (§5.1–5.2) | PIN-49 | Muse Code | | PIN-52 | Delete the game code, rename routes to boards | PIN-49 | Muse Code | | PIN-53 | Slice 1: photo → elements → pins | PIN-51, PIN-52 | Muse Code | diff --git a/docs/12-submission.md b/docs/12-submission.md index 50163796..96600435 100644 --- a/docs/12-submission.md +++ b/docs/12-submission.md @@ -1,4 +1,4 @@ -# 12 — Submission packet (PIN-81) +# 12. Submission packet _Status: freeze-prep packet, Oct 29, 2026, refreshed Oct 31 (PIN-219). Feature freeze Tue Nov 3, 2026 (end of day); @@ -29,6 +29,10 @@ dates and marks what the record does not show. §3 counts re-run on page; §5 records the F6/F7 pass; §6 adds "Who built it?"; §8 holds answers drafted to paste. Re-run §3 at the freeze commit. +Nov 4 refresh (PIN-274): §3 counts re-run on `dev` at `d958f29` (240 +merged PRs, 2,091 tests); [`build-log.md`](build-log.md) extended to +Nov 4 with the same sources. + ## 1. One-page pitch **Pinneds Studio — your stuff, pinned together.** @@ -79,7 +83,7 @@ PR numbers and dates: [`build-log.md`](build-log.md). | Judges' board (private link from the PIN-81 tracker entry; never commit; not the landing demo board, `docs/DEPLOY.md` §6.3) | `` | | Pitch video (~3 min, §2; public since Nov 3) | https://www.youtube.com/watch?v=q6lkrA6fsCI | | How the app uses Meta's models (in the app, on prod since promotion #2, Nov 2) | https://pinneds.com/models | -| Build log: who built what, with PR numbers | https://github.com/MoriaCrew/Pinned/blob/main/docs/build-log.md (on `main` only after the next promotion; use `blob/dev/…` until then) | +| Build log: who built what, with PR numbers | https://github.com/MoriaCrew/Pinned/blob/main/docs/build-log.md | | Dev backend (live models; the only live demo before Nov 1) | https://dev.pinneds.com | Notes for whoever fills this in: @@ -202,10 +206,10 @@ synthetic images. | Cost per photo (T5, **estimate**) | **≈ $0.020**/photo (bar ≤ $0.05). SAM bills one image per concept call (`usage.images_processed=1`); provider dashboard is authoritative | `eval/studio-findings.md` (T5); `docs/11` §8 | | Cost per scene (T5, **estimate**) | **≈ $0.01**/scene (bar ≤ $0.10). Meta's published Muse Image price is a flat $0.01 per generated image, whatever the tokens; the PIN-50 spike had priced tokens at Spark rates (≈ $0.022). Provider dashboard is authoritative | `src/gateway/costs.ts`; `docs/16-ai-usage.md`; `eval/studio-findings.md` (T5) | | Refusals (T6) | 0/10 harmless prompts refused (3 with synthetic people) | `eval/studio-findings.md` (T6) | -| Automated tests | **1,879 passed / 3 skipped** (165 files, one of them skipped), `AI_MOCK=1`, as of Nov 2, 2026 on `dev` (tip `a90cd7b`). **Recount at freeze**; CI never makes paid calls | `npm test` (`package.json`); CI config `.github/workflows/ci.yml` | -| Pull requests | **215 merged** (225 opened), including 3 promotions to `main`, as of Nov 2, 2026, 19:20 UTC — a moving number; **recount at freeze** (command below) | `gh pr list --state all --limit 500` | -| Review gate | All 215 merged PRs have a Cursor Bugbot check run on their final commit (209 clean, 6 neutral); Bugbot opened 241 review threads on 85 of them; CI and gitleaks passed on all 215 (as of Nov 2) | GitHub check runs; [`build-log.md`](build-log.md) §7 | -| Who built what | Muse Code: 98 merged PRs from its working copy only (setup, Meta model wiring, the 29-PR overnight core, the 9 Stripe PRs, sign-in, security fixes). Claude Code: 106. Both: 6. Promotions: 3. Not shown: 2 (as of Nov 2) | [`build-log.md`](build-log.md) | +| Automated tests | **2,091 passed / 3 skipped** (181 files, one of them skipped), `AI_MOCK=1`, as of Nov 4, 2026 on `dev` (tip `d958f29`). CI never makes paid calls | `npm test` (`package.json`); CI config `.github/workflows/ci.yml` | +| Pull requests | **240 merged** (252 opened), including 4 promotions to `main`, as of Nov 4, 2026, 16:00 UTC — a moving number (command below) | `gh pr list --state all --limit 500` | +| Review gate | All 240 merged PRs have a Cursor Bugbot check run on their final commit (234 clean, 6 neutral); Bugbot opened 264 review threads on 94 of them; CI and gitleaks passed on all 240 (as of Nov 4) | GitHub check runs; [`build-log.md`](build-log.md) §7 | +| Who built what | Muse Code: 98 merged PRs from its working copy only (setup, Meta model wiring, the 29-PR overnight core, the 9 Stripe PRs, sign-in, security fixes). Claude Code: 124. Both: 6. Promotions: 4. Not shown: 8, six of them Nov 3–4 PRs whose Linear close comments name Muse Code (as of Nov 4) | [`build-log.md`](build-log.md) | | Repo public | `MoriaCrew/Pinned`, visibility PUBLIC, AGPL-3.0 | `gh repo view` (Oct 29); `LICENSE`, `README.md` | | UI languages | Six translated by Muse Spark Open plus a reviewer pass; first pass **$0.98** in total. No native-speaker review yet | `docs/18-i18n.md` ("Costs", "Who reviewed what") | | Usage (people / pins / scenes) | Not a fixed number — captured fresh per §4 on record and submit days | `scripts/usage-report.mjs` (`npm run usage:report`) | @@ -287,7 +291,7 @@ approves. | F3 | Prod promotion: `dev` → `main`/prod per the hosting setup, then a real end-to-end play on `` (create board → pin → scene → remix → share → delete) | Robin + Agent (Robin owns promotion/approval; agent must never push `main`/prod itself) | `` plays the full loop with live models; `/api/health` answers | | F4 | Two boards on prod (`docs/DEPLOY.md` §6.3): the judges' board (the video board; link private, judging overrides) and the demo template board (`npm run seed:demo`; its slug, never the judges', is what `DEMO_BOARD_SLUG` holds; "See a board" copies it into a fresh board per visitor, PIN-251) | Agent prepares, Robin approves host/env changes | `` opens a populated, non-empty board; two "See a board" clicks land on two different populated boards, neither the judges' nor the template | | F5 | Hosted budget sanity: confirm the deployed `AI_DAILY_BUDGET_USD` / call limit env values are sized for judge + family traffic (`docs/11` A2: code default $5 / 500 calls, dev the same; prod $50 / 10,000 during judging per Robin, Oct 31, see `docs/DEPLOY.md`; verify the live values, do not assume) | Robin | Live values checked in the hosting env and matching A2 | -| F6 | This packet refreshed: §3 test count + PR count re-run at the freeze commit, §1 links block placeholders assigned owners. Nov 2 (PIN-248): re-run on `dev` `a90cd7b`; run again at the freeze commit | Agent | Packet matches freeze-commit reality | +| F6 | This packet refreshed: §3 test count + PR count re-run at the freeze commit, §1 links block placeholders assigned owners. Nov 2 (PIN-248): re-run on `dev` `a90cd7b`; Nov 4 (PIN-274): re-run on `dev` `d958f29` | Agent | Packet matches freeze-commit reality | | F7 | Known-v1-limits honesty pass on `README.md` + this file (1-day `tmp/` lifecycle backstop applied vs the app-side delete, cost-estimate labelling, optional sign-in scope, non-Meta Higgsfield assets disclosed). Nov 2 (PIN-248): lifecycle, estimate labels and Higgsfield disclosure checked; §6 sign-in scope corrected (optional on prod too); build claims now cite `build-log.md`. Open: the `/models` "Built with Meta tools" line names only Muse Code (PIN-249, operator's call before freeze) | Agent | No claim in the packet or README that the code doesn't back | ### Video (Nov 3–4) @@ -580,6 +584,6 @@ a reviewer pass for six languages, $0.98 for the first pass in total (`docs/18-i18n.md`). The opinion part is the submitter's. **Survey items** — the submitter's own answers. Facts for the Muse -Code question: 98 of the 215 merged PRs came from its working copy +Code question: 98 of the 240 merged PRs came from its working copy only, including the 29-PR overnight Studio core and the 9 Stripe -PRs (as of Nov 2; `build-log.md`). +PRs (as of Nov 4; `build-log.md`). diff --git a/docs/13-auth.md b/docs/13-auth.md index 7801a08c..a52e320e 100644 --- a/docs/13-auth.md +++ b/docs/13-auth.md @@ -1,23 +1,27 @@ -# 13 — Sign-in (`AUTH_MODE`, PIN-57) +# 13. Sign-in -Server foundation landed in PIN-83; the sign-in button (PIN-84) -and the cross-board library (PIN-85) build on it. Spec: -`docs/11-studio.md` section 5.4; the original claim flow is in -`docs/archive/02`. +Sign-in is optional and sits behind `AUTH_MODE` (PIN-57). With +`optional`, a participant may sign in with Google to get a personal +library across boards (My boards and My pins); it never gates a +board, because the board link stays the only way in. The server +foundation is PIN-83, the sign-in button PIN-84 and the cross-board +library PIN-85. Spec: `docs/11-studio.md` section 5.4. ## Modes -- `AUTH_MODE=link-only` (the default; prod today): board - participants never authenticate. Every route below returns the - uniform 404 and no user code runs on the board flow — the - flag-off parity tests in `src/server/user-session.test.ts` and +- `AUTH_MODE=link-only` (the default in code and in + `apphosting.yaml`): board participants never authenticate. Every + route below returns the uniform 404 and no user code runs on the + board flow. The flag-off parity tests in + `src/server/user-session.test.ts` and `src/app/api/auth/auth-routes.test.ts` are the contract. -- `AUTH_MODE=optional`: a participant *may* sign in with Google. +- `AUTH_MODE=optional` (set in `apphosting.dev.yaml` and + `apphosting.prod.yaml`): a participant *may* sign in with Google. The link-only board flow keeps working unchanged; sign-in only adds identity linking. Unknown `AUTH_MODE` values fail closed to `link-only` -(`src/config.ts`, FR8). +(`parseConfig` in `src/config.ts`). ## Flow (optional mode) @@ -31,8 +35,8 @@ Unknown `AUTH_MODE` values fail closed to `link-only` added by PIN-83 solely for this verification. PIN-94: exchange always verifies with revocation checking, and only Policy `authAllowedProviders` (default `google.com`) may mint a - session — enabling another Firebase provider (e.g. Anonymous) - in a console cannot sign anyone in by itself. PIN-160: the + session, so enabling another Firebase provider (Anonymous, for + example) in a console cannot sign anyone in by itself. PIN-160: the exchange refuses a cross-site request (403 `cross_site`) or a non-JSON body (415) before any body read, so another site cannot plant its own session in a visitor's browser (login @@ -50,10 +54,10 @@ Unknown `AUTH_MODE` values fail closed to `link-only` automatically (never overwriting a different user's claim, never failing the join). Both paths maintain the `users/{uid}/boards/{slug}` index (doc-get/subcollection - reads only — no new Firestore index). Freshness (PIN-94, + reads only, so no new Firestore index). Freshness (PIN-94, narrowed by PIN-160): the user cookie carries the token's `auth_time`, and the Policy `authFreshWindowMs` window - (default 1h) now protects restore only — the one path that + (default 1h) now protects restore only, the one path that mints a participant cookie from the account alone; a stale restore restores nobody. The explicit claim route keeps its check (401 `stale_auth`), but join and board-open link the @@ -68,35 +72,35 @@ Unknown `AUTH_MODE` values fail closed to `link-only` ## What is stored `users/{uid}` holds `uid`, optional `displayName` / `photoUrl` -from the token, and timestamps. **Email is never stored** — -nothing sends mail or keys identity off an address; the token's -email claim is not even read. The profile and board index -persist until a future account-deletion flow lands (not yet -built; one board's row goes with "Remove from My boards", -PIN-166); `userSessionDays` bounds the cookie only. +from the token, and timestamps. **Email is never stored**: +nothing sends mail or keys identity off an address, and the +token's email claim is not even read. The profile and board index +persist: there is no account-deletion flow yet (one board's row goes +with "Remove from My boards", PIN-166), and `userSessionDays` +bounds the cookie only. ## Operator steps to enable (per backend) -Status Oct 29, 2026: done for both projects. The Google -provider + authorized domains are enabled in dev and prod; -the web config lives in Secret Manager under the -`NEXT_PUBLIC_FIREBASE_*` names (referenced from -`apphosting.yaml`, never literals in git — gitleaks enforces -this). Dev runs `AUTH_MODE=optional` (PR #76); prod flips at -promotion (PIN-76). The original runbook, for a new backend: - -1. Firebase console → Authentication → enable the Google - provider for the backend's project, and add the backend's - custom domain under Authentication → Settings → - Authorized domains. -2. Register/copy the web app config; store +_Operators only: this section is for whoever runs the hosted service; a visitor can skip it._ + +Both projects (dev and prod) have the Google provider and the +authorized domains enabled, and both run `AUTH_MODE=optional`. The +web config lives in Secret Manager under the +`NEXT_PUBLIC_FIREBASE_*` names (referenced from `apphosting.yaml`, +never literals in git; gitleaks enforces this). The runbook, for +a new backend: + +1. In the Firebase console, open Authentication and enable the + Google provider for the backend's project, then add the + backend's custom domain under Settings, Authorized domains. +2. Register and copy the web app config; store `NEXT_PUBLIC_FIREBASE_API_KEY` and `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN` in Secret Manager (`firebase apphosting:secrets:set` + `grantaccess` for the backend) and set `AUTH_MODE=optional` in that backend's `apphosting.*.yaml`. The root layout reads the values server-side and may prerender at build, so the shared - config grants BUILD + RUNTIME availability — a rebuild is + config grants BUILD + RUNTIME availability, and a rebuild is required either way. 3. Make sure the runtime service account can verify tokens (Application Default Credentials; App Hosting's compute @@ -107,11 +111,11 @@ promotion (PIN-76). The original runbook, for a new backend: Until step 2 lands for a backend, the `NEXT_PUBLIC_*` values resolve empty and the sign-in UI stays hidden even in -optional mode — no dead buttons. +optional mode, so there are no dead buttons. -## Auth domain: sign-in on the app's own host (PIN-163) +## Auth domain: sign-in on the app's own host -By default the web config's `authDomain` is the +PIN-163. By default the web config's `authDomain` is the `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN` secret, the project's `{project}.firebaseapp.com`, so Google's account chooser names that address. Putting the app host in the secret alone does not @@ -134,33 +138,33 @@ paths, which Firebase Hosting serves and App Hosting does not project's own firebaseapp host, where only Firebase's reserved helper answers on these paths. 2. authDomain: `src/config.ts` then serves the host of - `APP_BASE_URL` instead of the secret — only for an https + `APP_BASE_URL` instead of the secret, only for an https `APP_BASE_URL` and a valid project id (the proxy must exist); otherwise the secret's value, unchanged. The client project id always comes from `FIREBASE_PROJECT_ID`: `resolveAuthUi` never derives one from a non-firebaseapp authDomain. -3. Flag off (the default, and prod today) is byte-identical to +3. Flag off (unset, the default in code) is byte-identical to before; rollback is removing the flag and rebuilding. +`apphosting.dev.yaml` and `apphosting.prod.yaml` both set +`AUTH_SAME_ORIGIN` to `1`, so Google's chooser names the app host +on `dev.pinneds.com` and `pinneds.com`. + +_Operators only: the rest of this section is console work, done once per environment._ + Operator step, per environment (console, not code): -- Google Cloud console → APIs & Services → Credentials → the - project's web OAuth client → add the authorized redirect URI - `https://{app host}/__/auth/handler`, and keep the app host in - Firebase Authentication → Settings → Authorized domains. -- Dev: done Oct 30 for `dev.pinneds.com`; `apphosting.dev.yaml` - sets `AUTH_SAME_ORIGIN=1`. -- Prod, at promotion: add `https://pinneds.com/__/auth/handler` - to the prod project's web client, confirm `pinneds.com` is an - authorized domain, then set `AUTH_SAME_ORIGIN: "1"` beside - `AUTH_MODE: optional` in `apphosting.prod.yaml`. +- In the Google Cloud console, open APIs & Services, Credentials, + the project's web OAuth client, and add the authorized redirect + URI `https://{app host}/__/auth/handler`. Keep the app host in + Firebase Authentication, Settings, Authorized domains. - Smoke after the rebuild: `GET https://{app host}/__/auth/handler` answers 200 (not the app's 404), and the Google chooser names the app host. -## Sign-in UI + My boards + restore (PIN-84) +## Sign-in UI, My boards and restore -The client half of PIN-57b, still behind the same gates: +PIN-84 is the client half, behind the same gates: 1. Visibility: `resolveAuthUi` (src/domain/auth-ui.ts) enables the UI only when `AUTH_MODE=optional` AND the public web @@ -186,7 +190,7 @@ The client half of PIN-57b, still behind the same gates: signed out. Each card's More menu has "Remove from My boards" (PIN-166): `DELETE /api/me/boards/{slug}` deletes the caller's own index row AND clears `userId` on the caller's own - participant there (only when it carries that uid) — never the + participant there (only when it carries that uid), never the board, nor anyone else's row or claim. The row alone would not hold (the next signed-in join or fresh `/claim` rewrites it). Side effects: the board's pins leave My pins and restore on @@ -197,12 +201,12 @@ The client half of PIN-57b, still behind the same gates: 4. Restore + auto-claim: in optional mode, `GET /api/boards/{slug}` with a user session but no participant cookie re-issues the participant cookie when the user holds - a claim on that board (the slug link is still required — + a claim on that board (the slug link is still required; sign-in never substitutes for it), and an unclaimed participant opening a board while signed in is claimed silently. Restore rotates `cookieSecretHash` like the reclaim-PIN path, so the previous device's cookie stops - working — the single-hash record cannot hold two live + working, because the single-hash record cannot hold two live cookies. Restore needs a fresh sign-in (PIN-94); the silent claim works at any sign-in age (PIN-160). Neither runs for a request the browser labels cross-site (`Sec-Fetch-Site` other @@ -214,13 +218,12 @@ The client half of PIN-57b, still behind the same gates: Policy `signInNudge` (default true) and only ever shown when sign-in is available. -Live Google proof still depends on the operator steps above; -until then the hosted dev backend stays link-only and none of -this renders there. +A backend that has not finished the operator steps above stays +link-only in practice: none of this renders there. -## My pins library + pin-to-this-board (PIN-85) +## My pins library and pin-to-this-board -The cross-board library that closes PIN-57, behind the same +PIN-85 is the cross-board library that closes PIN-57, behind the same gates (404 in link-only mode, 401 signed out): 1. Library: `GET /api/me/elements` and the `/me/pins` page @@ -228,19 +231,19 @@ gates (404 in link-only mode, 401 signed out): where their claim is live (user board index -> claimed participant -> `listElementsByOwner`), newest first, capped at Policy `libraryPageSize` (default 60). Only - PINNED things are listed (PIN-125): a candidate still in + PINNED elements are listed (PIN-125): a candidate still in review, left out, or cleared from the board's leftovers line keeps its element but never shows in the library (one pin read per board filters them). Cards - carry the source board's TITLE only — a slug never appears + carry the source board's TITLE only; a slug never appears in a library payload or URL. Each card also carries `familyId` (PIN-121): the original element's id for a - library copy (`sourceElementId`), else its own id — always - one of the caller's own things — so the Add sheet can tell - which things already sit on the board it is open on. + library copy (`sourceElementId`), else its own id (always + one of the caller's own pins), so the Add sheet can tell + which pins already sit on the board it is open on. 2. Thumbnails: `GET /api/me/elements/{id}/image` authorizes by signed-in OWNERSHIP (the element's owner participant - must be claimed by the caller), not by board link — the + must be claimed by the caller), not by board link: the cross-board counterpart of the board-link image route. Every miss is the uniform 404. It stays `private, no-store`: the session, not the URL, authorizes it, so a cached copy @@ -252,7 +255,7 @@ gates (404 in link-only mode, 401 signed out): participant; pinning twice returns `alreadyPinned`, never an error. The board Add sheet shows it under the photo target as the "From your other boards" strip (PIN-121; one - sticker per family, things already here greyed as "Already + sticker per family, pins already here greyed as "Already here", capped at Policy `libraryStripMax`, default 12) with "See all" for the full list; `/me/pins?board=` offers the same action per card. @@ -262,9 +265,9 @@ gates (404 in link-only mode, 401 signed out): element under the pin's own board, so a true cross-board Pin cannot be written in this layout (and the delete cascade assumes the same). Pinning cross-board therefore - creates a copy Element on the target board — new id, - `sourceElementId` provenance, the SAME cutout/flattened - storage refs — owned by the current participant (the same + creates a copy Element on the target board (new id, + `sourceElementId` provenance, the SAME cut-out and flattened + storage refs) owned by the current participant (the same signed-in user, so credit stays theirs). Copies compose, remix and cascade natively from then on. `deleteMyElement` guards the shared objects: deleting the original or a @@ -276,9 +279,9 @@ gates (404 in link-only mode, 401 signed out): its Firestore form needs the `sourceElementId` collection- group field index declared in `firestore.indexes.json`. -## Scene delete (PIN-142, parent PIN-132) +## Scene delete -`DELETE /api/boards/{slug}/scenes/{sceneId}`: the scene's maker +PIN-142. `DELETE /api/boards/{slug}/scenes/{sceneId}`: the scene's maker (participant cookie) or the board host (host cookie, see `docs/15-board-plus.md`). After the slug gate and the cross-site 403 (no I/O), every failure before authorization is the uniform 404: no @@ -295,9 +298,9 @@ route's `max-age` ends. The board view marks each scene `canDelete` (maker or host; only a UI hint, PIN-143), so a host's Delete shows after the first client poll (the page render has no host cookie). -## Board delete (PIN-167) +## Board delete -`DELETE /api/boards/{slug}`: the board's host only (host cookie). +PIN-167. `DELETE /api/boards/{slug}`: the board's host only (host cookie). Bodyless. After the slug gate come the cross-site 403 and the `Policy.boardDeletePerIp` 429, before any read; every other failure is the uniform 404 (no or bad host cookie, another board's host, an @@ -318,9 +321,9 @@ after writing, and My boards skips one anyway, so a row left behind links nothing. Jobs in flight are not waited for: their board-record writes (elements, pins, scenes, events) are refused and they end quietly, but a job already running can still finish its AI call, update its -own job doc and debit one make from the board's credit account. The +own job doc and debit one creation from the board's credit account. The Firestore records stay (no route serves them; the shared-asset check -below reads them). A library copy of a thing made on another live +below reads them). A library copy of a pin made on another live board before the delete keeps working for its owner (it shares the original's bytes). Concurrent deletes: only the call that set the tombstone gets 204; the other gets the uniform 404. Errors on these @@ -328,9 +331,9 @@ paths are logged with slug-shaped tokens blanked (`src/server/safe-log.ts`). Storage (PIN-169, `src/server/board-storage.ts`): then the board's -three folders, built from the valid slug with the trailing slash — -`tmp/boards/{slug}/`, `elements/boards/{slug}/`, -`scenes/boards/{slug}/` — are listed and emptied, best-effort (never +three folders, built from the valid slug with the trailing slash +(`tmp/boards/{slug}/`, `elements/boards/{slug}/` and +`scenes/boards/{slug}/`), are listed and emptied, best-effort (never fails the 204; errors logged by name only). Only an element folder whose cut-out a library copy on a live board still points at stays whole, decided from storage, so it holds even when the source record @@ -361,10 +364,10 @@ Everyone else with the board open: the board poll's 404 (not a generic load error) renders the same not-found as an unknown link, which unmounts the board and so ends the polling. -## Thing rename (PIN-156, parent PIN-134) +## Pin rename -`PATCH /api/boards/{slug}/elements/{elementId}` with JSON -`{ label }`: the thing's owner only (participant cookie). After the +PIN-156. `PATCH /api/boards/{slug}/elements/{elementId}` with JSON +`{ label }`: the pin's owner only (participant cookie). After the slug gate come the checks that read nothing, so they answer the same for every element: cross-site 403, non-JSON 415, a body over `Policy.elementRenameMaxBytes` 413. Then every miss is the uniform diff --git a/docs/14-design-system.md b/docs/14-design-system.md index e7dc98e4..963d2ba4 100644 --- a/docs/14-design-system.md +++ b/docs/14-design-system.md @@ -1,9 +1,10 @@ -# 14 — Pinneds design system (PIN-95) +# 14. Pinneds design system -Status: **Accepted** by Robin on Oct 29, 2026. Replaces the -first-generation Studio look (`docs/archive/09`), which read as -AI-generated. The product flow (`docs/11` §6) is unchanged; the -look and the interaction design are rebuilt from scratch. +The look and interaction design of Pinneds, decided on Oct 29, 2026 +(PIN-95). It replaces the first-generation Studio look +(`docs/archive/09-design-system.md`), which read as AI-generated. The +product flow (`docs/11` §6) is unchanged; the look and the +interaction design were rebuilt from scratch. ## The idea @@ -41,7 +42,7 @@ Principles: ## UI kit - `src/ui/` holds the components (`import { … } from "../ui"`): - Icon (24 line icons), Wordmark, Button/ButtonLink, IconButton, + Icon (26 line icons), Wordmark, Button/ButtonLink, IconButton, Chip, Tag, Avatar/AvatarStack, TextField, Hint, Sticker, Pin, CreditLine, SceneCard, Developing, BoardHeader, SectionHead, FilterChips, Dock, Sheet, EmptyState, Menu, SkinPicker, TryOnBar, @@ -124,7 +125,9 @@ Principles: skin with synthetic samples from `public/lab/`; use it for QA and PR screenshots. -## Landing and library screens (PIN-104) +## Landing and library screens + +PIN-104 put the landing, My boards and My pins on the kit. - Landing (`/`), My boards, My pins and the not-found page sit on the Paper skin (`data-theme="paper"` + `pn-skin-root`, class @@ -246,9 +249,9 @@ fill on its cream ground (2.1:1). `public/fonts/LICENSES.md`); the reading face and Bricolage Grotesque are preloaded. -## Link card (PIN-102) +## Link card -A board link pasted into a chat shows a 1200 x 630 card in the +PIN-102. A board link pasted into a chat shows a 1200 x 630 card in the board's skin (`src/app/g/[slug]/opengraph-image.tsx`, reused by `twitter-image.tsx`): wordmark, "{name} invited you to pin your things to" (earliest participant; dropped when nobody joined), the @@ -290,10 +293,11 @@ No pins yet: three dashed ghost outlines. | Oct 30 | A thing's name tag wraps to up to 3 lines, "…" only past that (PIN-164) | One line cut most names after ~9 letters in the wide uppercase looks | | Oct 31 | One purchase replaces the Plus plans and makes packs: €2.99 once per board for Plus + 20 creations, bought by any joined member for the board; free pool 5; members change the look once the board has Plus; no host plan sold (PIN-181; supersedes the Oct 29 Plus row) | One clear thing to buy; guests never have to pay, and anyone who wants more can give it to the board | | Oct 31 | One Board Plus sheet is the only screen that asks for money: the perks plus one €2.99 purchase (Plus + 20 creations) for the board, through the credits checkout; no plan cards, no Host plan, no separate "Add makes" sheet; makes are called "creations" in the UI (PIN-182) | One clear thing to buy and one word for what it buys; any member can give it to the board, and nobody sees a price list | -| Oct 31 | The Make sheet is a chat-style composer: mic and Stop are icon buttons inside the field (the chat convention, an exception to "icon-only for close, share, more"), "Make it" stays labelled; ideas are one scrolling row; tapping a pressed chip takes it back out (PIN-216) | Robin: the mic below the field felt awkward; the old sheet overflowed a 390x844 phone and wiped typed text | +| Oct 31 | The Make sheet is a chat-style composer: mic and Stop are icon buttons inside the field (the chat convention, an exception to "icon-only for close, share, more"), "Make it" stays labelled; ideas are one scrolling row; tapping a pressed chip takes it back out (PIN-216) | The operator found the mic below the field awkward; the old sheet overflowed a 390x844 phone and wiped typed text | | Oct 31 | The creations indicator speaks only at `lowMakesWarning` or fewer left, as one quiet board-meta line with a text button (no card, nothing above the threshold); running out is an invitation into the Plus sheet, never an error; the count lives in the you menu as Board Plus; any joined member can change the look once Plus is on (PIN-183) | A board with plenty looks like a credits-off board; money is offered when it helps, not shown all the time | | Oct 31 | Busy boards get a sticky filter row above the pins (New / All / Mine / people) instead of reordering or paging the grid; the grid stays oldest first (PIN-218) | 100+ pins from 18 people is 40 rows; finding your own or a friend's pins, or what is new, should take one tap, and polling must never reshuffle the grid | | Oct 31 | When a page throws, visitors get the Paper dead end "Something came unpinned." with Try again and Go home, never the error, its message or digest; `global-error` carries the same screen when the root layout itself fails (PIN-212) | Judges and first-time visitors never see Next's bare "Application error"; same quiet look as not-found, one primary action | | Oct 31 | The Make sheet rides above the phone keyboard; measured, not configured: a `visualViewport` hook sets `--pn-keyboard-inset` on the sheet (0 with the keyboard closed or while pinch-zoomed), which lifts it and takes the same height off its scrolling middle; no glide under reduced motion; the root viewport keeps the default `interactive-widget` (PIN-226) | iOS Safari and Chrome on Android lay the keyboard over the page, so it covered the field and Make it while typing; changing the viewport would move every page on Android | | Oct 31 | Name tags never auto-hyphenate (`hyphens: manual`); a pin's owner name ends with "…" when it does not fit, full name in `title`; the picking dock's tray slots shrink (44px down to about 34px) so a long Next label never leaves the dock (PIN-225) | Spark names things in English and owners can rename in any language, but the browser hyphenates by the page language ("Pot-ted Succulent" in fr and de); es "Siguiente" and de "Weiter" pushed the Next button past the dock edge at 390px | | Oct 31 | A board's free photos (PIN-230) show only in the Add sheet and only when close: nothing above `lowFreePhotosWarning` (3), one quiet line under the picker at or under it, and at 0 the Get Plus invitation in the picker's place (library pins stay); the Plus sheet lists "Unlimited photos" after the creations perk (PIN-231) | The limit should be a cap people rarely feel, not a meter; hitting it is an offer, never an error | +| Nov 4 | "Report this board" opens a sheet instead of a mail (PIN-271, ADR-004): what (this board, a pin by label, a scene by number) and why as pressed chips, a message, an optional email, Cancel and Send; the receipt shows the opaque reference and the legal address stays as plain text; a board under a report hold shows the one-line `pn-lib-notice` and its write actions answer with a friendly line | One tap on a mailto sent an empty report by mistake on Nov 4; a reason and a confirm step are what stop that, and an in-app record is what lets the app hide the reported thing at once | diff --git a/docs/15-board-plus.md b/docs/15-board-plus.md index 3b4584cb..1dffaab3 100644 --- a/docs/15-board-plus.md +++ b/docs/15-board-plus.md @@ -1,17 +1,32 @@ -# 15 — Board Plus (PIN-103) - -Server seam for an optional paid add-on. The UI is built -separately; this page is the contract it builds on. -`PLUS_MODE` is `off` (nothing can be bought), `mock` (demo) or -`stripe` (the Board Plus pack via hosted Checkout, PIN-149; the -makes and Plus land by webhook only, PIN-150 / PIN-181). +# 15. Board Plus and creations + +Server seam for an optional paid add-on, introduced in PIN-103. +The UI is built separately; this page is the contract it builds on. +`PLUS_MODE` is `off` (nothing can be bought, the default), `mock` +(demo) or `stripe` (the Board Plus pack via hosted Checkout, +PIN-149; the creations and Plus land by webhook only, PIN-150 / +PIN-181). + +**In one screen.** Board Plus is the one paid thing in Pinneds: a +single pack, bought once for one board, that unlocks every look and +foil stickers, removes the free-photo limit and adds 20 creations, +for €2.99 through hosted Stripe Checkout. Any member can buy it for +everyone on the board. A creation is one AI scene (adding a photo and +cutting it out never uses one); when a board runs out, scenes fall +back to free collages, so nothing stops working and guests never have +to pay. New boards start with a free pool of 5 creations and 15 free +photos, for a maker's first 3 boards in 7 days (prod gives 20 +creations during judging week). Nothing is sold while `PLUS_MODE=off`, +the code default; dev runs Stripe in test mode and prod runs live +Stripe. The rest of this page is the contract for the code and the +operator runbook. ## What it is - The core loop is free while `Policy.credits.enabled` is off - (the shipped default, PIN-151): uploads, cut-outs, pins, scenes - and the AI budget are unchanged. With credits on, AI makes - ("creations" in the UI) spend the board pool instead (PIN-151 + (the code default, PIN-151): uploads, cut-outs, pins, scenes + and the AI budget are unchanged. With credits on, AI creations + (`makes` in code) spend the board pool instead (PIN-151 below). **A creation is one AI scene (PIN-229):** adding a photo and its cut-outs never uses one, so discarding cut-outs costs nothing; every AI scene uses one, even one the person does not @@ -35,7 +50,7 @@ makes and Plus land by webhook only, PIN-150 / PIN-181). > - **Who is a maker:** the signed-in user when sign-in is on and > the request carries a session, else the client address the > rate limiter uses (`clientIp` in `src/server/rate-limit.ts`, -> PIN-112) — IPv4 as is, IPv6 grouped by its /64, anything +> PIN-112): IPv4 as is, IPv6 grouped by its /64, anything > unreadable in one shared bucket. People behind one shared > IPv4 (office, school or event Wi-Fi, some mobile carriers) > share one allowance while signed out; signing in gives each @@ -57,8 +72,8 @@ makes and Plus land by webhook only, PIN-150 / PIN-181). > unchanged. > - **The hard wall** stays the site-wide daily AI budget > (`dailyBudgetUsd` / `dailyCallLimit`), which free and paid -> makes share today (PIN-206 splits it). Tuning ideas after -> launch: PIN-207. +> creations share today (splitting them is tracked as PIN-206). +> Tuning ideas after launch: PIN-207. > **Free photos rule (PIN-230).** Since a creation is an AI scene > (PIN-229), a free board's photo cost (~$0.02 a photo, docs/16) @@ -86,12 +101,12 @@ makes and Plus land by webhook only, PIN-150 / PIN-181). > use the Policy value. A board's own override > (`policyOverrides.credits.freePhotosPerBoard`) wins over the > stored amount: an operator's explicit choice for that one -> board. (The demo board needs none: it is a normal board and -> gets Plus, so no photo limit, once billing is live, DEPLOY -> §6.4.) +> board. (The judges' board needs none: it is a normal board that +> gets Plus, so it has no photo limit, once billing is live; +> `docs/DEPLOY.md` section 6, step 4.) > - **At the limit:** `POST /photos` answers a typed 429 > `{ code: "free_photos_used" }` before intake, any job or any -> AI call — checked before the daily cap, since waiting a day +> AI call, checked before the daily cap, since waiting a day > would not help. The batch queue stops on it like the other > coded 429s. A lost-response replay of a counted photo still > gets its job back. @@ -125,11 +140,13 @@ makes and Plus land by webhook only, PIN-150 / PIN-181). existing `Board.themeId`; `isBoardThemeId` accepts every skin id plus the first-generation theme ids. -## The purchase (PIN-181) +## The purchase + +Introduced in PIN-181. | What | Who can buy | Billing | Grants | Policy | | --- | --- | --- | --- | --- | -| Board Plus · 20 creations (`pinneds_plus_20`) | any joined member or the host | €2.99 once, this board | 20 makes + `Board.plus = { plan: "board", source: "stripe" }` | `credits.packs`, `credits.plusOnPurchase` | +| Board Plus · 20 creations (`pinneds_plus_20`) | any joined member or the host | €2.99 once, this board | 20 creations + `Board.plus = { plan: "board", source: "stripe" }` | `credits.packs`, `credits.plusOnPurchase` | It is bought through the credits buy route (PIN-149) and granted by the webhook (PIN-150 section). The plan ids `board` / `host` @@ -258,7 +275,7 @@ host-only but answers every failure with the uniform 404 - `GET /billing/return?intent={id}&result=success|cancel` → 302. The return redirector Stripe points at: it resolves the opaque intent server-side and lands on `/g/{slug}?billing=processing` - (success) or `?billing=cancelled` (cancel). It grants nothing — + (success) or `?billing=cancelled` (cancel). It grants nothing: credits arrive by webhook only (PIN-150), hence "processing". Unknown, expired and malformed intents all land on `/` with no flag, indistinguishable from each other. It is read-only @@ -320,33 +337,38 @@ is `off`. Adapters live in `src/billing/` behind the `Checkout` port (`startCheckout({ slug, plan })`, which throws a typed `BoardNotFoundError` when the board is gone). -- `off` (default; prod): every checkout answers `unavailable` and +- `off` (the code default, used when `PLUS_MODE` is unset or not + an exact known value): every checkout answers `unavailable` and writes nothing. A leftover `source: "mock"` grant **does not count** in `off` (`hasPlus(board, mode)`): it unlocks no skin and the view shows `active: false`. A board already wearing a Plus skin keeps the stored id; the UI decides how to render - that (follow-up). The UI should present Plus honestly as not - available yet — never a buy button that pretends. -- `mock` (dev backend via `apphosting.dev.yaml`; locally via - `.env.local`): grants the entitlement at once with + that (follow-up). The UI presents Plus honestly as not + available, never a buy button that pretends. +- `mock` (locally via `.env.local`; the dev backend used it + until PIN-153): grants the entitlement at once with `source: "mock"`. No money moves; the UI must say it is a demo. The stored source keeps mock grants distinguishable forever. Never set `mock` on prod. - `stripe` (PIN-149): sells the Board Plus pack through hosted - Stripe Checkout (adapter section below). Requires - `STRIPE_SECRET_KEY` in the same env — `stripe` without a key - parses as `off` in config (fail closed: sells nothing, never - errors per request; `GET /api/health` shows the effective - mode). Plus comes only with that pack (PIN-181): the plan - route still answers `unavailable`, and a stored + Stripe Checkout (adapter section below). Requires both + `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` in the same env, + and, when `STRIPE_EXPECTED_LIVEMODE` (`live` or `test`) is set, + a key of that mode. Otherwise `stripe` parses as `off` in config + (fail closed: sells nothing, never errors per request; + `GET /api/health` shows the effective mode; `src/config.ts`, + `parsePlusMode`). Plus comes only with that pack (PIN-181): the + plan route still answers `unavailable`, and a stored `source: "stripe"` grant counts while a mock one does not. - **Dev runs `stripe` since PIN-153** (sandbox only: the dev - backend flips together with `Policy.credits.enabled` and - the registered webhook endpoint - PIN-153 section below). - Prod stays `off` until the PIN-210 prod switch turns on live - Stripe ("Going live on prod" below). PIN-149/PIN-150 - deliberately shipped reference-only so no backend ever took - a payment with no fulfilment path. + **Dev runs `stripe` since PIN-153** (sandbox only, with + `STRIPE_EXPECTED_LIVEMODE=test`: the dev backend flips together + with `Policy.credits.enabled` and the registered webhook + endpoint, PIN-153 section below). **Prod runs `stripe` with live + Stripe since the PIN-210 prod switch** (`apphosting.prod.yaml` + on `main`, `STRIPE_EXPECTED_LIVEMODE=live`; "Going live on + prod" below). PIN-149/PIN-150 deliberately shipped + reference-only so no backend ever took a payment with no + fulfilment path. Check a backend with `GET /api/health` → `plusMode`. @@ -381,11 +403,14 @@ list. | `checkoutPerIp` | 20 / 600,000 ms | buy route per-IP brake | | `webhookMaxBytes` | 1,048,576 | webhook body cap (PIN-178) | -## Stripe catalog (PIN-146) +## Stripe catalog + +_Operators only: this section is for whoever runs the hosted service; a visitor can skip it._ -What Pinneds sells is committed code: `src/billing/catalog.ts` +Introduced in PIN-146. What Pinneds sells is committed code: +`src/billing/catalog.ts` (`CATALOG_ITEMS`, catalog version `dev-v1`). Amounts are EUR -cents, tax-inclusive (TTC) — the displayed price is the charged +cents, tax-inclusive (TTC): the displayed price is the charged price. Runtime slices import the manifest; nothing reads Stripe object ids from config. @@ -395,7 +420,7 @@ object ids from config. | `pinneds_credits_25` | 25 makes | €4.99 one-time | retired (PIN-181) | | `pinneds_credits_60` | 60 makes | €9.99 one-time | retired (PIN-181) | | `pinneds_credits_150` | 150 makes | €19.99 one-time | retired (PIN-181) | -| `pinneds_host_monthly` | host plan | €4.99/month recurring | draft — seeded, not offered | +| `pinneds_host_monthly` | host plan | €4.99/month recurring | draft, seeded, not offered | "Creations" is the customer-facing word for makes; code keeps `credits`/`makes`. The Checkout page shows the item's name @@ -420,7 +445,7 @@ when a price is replaced. `scripts/seed-stripe-catalog.mjs` converges a Stripe **sandbox** onto the manifest (plain `fetch`, no SDK; it mirrors the -manifest — a test pins the mirror). It is a dry-run unless +manifest; a test pins the mirror). It is a dry-run unless `--apply` is passed, takes the secret from the env var named by `--env-var` (never printed; a live key is refused unless `--live --i-understand-live`, PIN-210), and prints item keys and @@ -434,7 +459,7 @@ node --env-file=.env.local scripts/seed-stripe-catalog.mjs --env-var STRIPE_PINN Convergence: a product matched by metadata is reused (updated in place on copy/metadata drift); a price matched by lookup key is reused only when amount, currency, product, tax behavior and -recurrence all match — those fields are immutable, so drift +recurrence all match (those fields are immutable), so drift mints a new price with `transfer_lookup_key=true` and archives the old one. The price lookup reads archived prices too (Stripe lists only active ones unless asked, paginated like every list): @@ -449,15 +474,14 @@ idempotency keys, and re-running a converged sandbox creates nothing. The pure decision functions (`catalogValidationErrors`, `planProduct`, `planPrice`, `buildCatalogPlan`) are unit-tested in -`src/billing/catalog.test.ts`. This slice changes no runtime -behavior in that slice; the runtime adapter followed in -PIN-149 (below). +`src/billing/catalog.test.ts`. That change added no runtime +behaviour; the runtime adapter followed in PIN-149 (below). -## Credit ledger (PIN-147) +## Credit ledger -`src/domain/credit-ledger.ts` is the pure per-board makes ledger -(board-loop debit wiring lands in PIN-151, below): -no Stripe calls in the domain — checkout/webhook +Introduced in PIN-147. `src/domain/credit-ledger.ts` is the pure +per-board makes ledger (board-loop debit wiring landed in PIN-151, +below): no Stripe calls in the domain. The checkout and webhook slices translate Stripe events into its operations, taking amounts from `Policy.credits` (packs pinned to the PIN-146 catalog by a test, `starterMakes` granted once per board (5 since PIN-181), @@ -501,9 +525,9 @@ dispute's (PIN-233); debits spend only `available`. Persistence sits behind the operation is one atomic per-board read-modify-write and a refusal writes nothing. -## Credit storage + checkout intents (PIN-148) +## Credit storage + checkout intents -`FirestoreCreditLedgerStore` +Introduced in PIN-148. `FirestoreCreditLedgerStore` (`src/server/firestore-credit-ledger-store.ts`) implements the ledger port by running the pure ops inside one Firestore transaction per board, so concurrent debits cannot overspend and @@ -511,12 +535,12 @@ concurrent grants cannot double-credit; a throw aborts the transaction and writes nothing. Storage is multi-doc so no document grows unbounded: -- `creditAccounts/{boardId}` — the account head only (`balance`, +- `creditAccounts/{boardId}`: the account head only (`balance`, `frozen`, `pendingClawback`, `starterGranted`, `frozenByDispute`). -- `creditAccounts/{boardId}/purchases/{purchaseId}` — one doc +- `creditAccounts/{boardId}/purchases/{purchaseId}`: one doc per recorded purchase. -- `creditAccounts/{boardId}/entries/{eventId}` — one append-only +- `creditAccounts/{boardId}/entries/{eventId}`: one append-only doc per applied entry, keyed by event id, with an internal `_seq` (append order, stripped on reads; the board-repository pattern). @@ -528,7 +552,7 @@ restart. Unknown boards read a zero balance. All access is direct doc reads plus whole-subcollection reads sorted in code: **no new composite indexes**, so `firestore.indexes.json` is unchanged and no index deploy is needed for this slice. -`firestore.rules` stays deny-all — the Admin SDK server path +`firestore.rules` stays deny-all: the Admin SDK server path owns these collections. `CheckoutIntent` (`src/server/checkout-intent.ts`, Firestore impl @@ -538,12 +562,12 @@ UUID intent id; the board mapping exists only inside the stored record at `checkoutIntents/{intentId}` (top-level by intent id, so a webhook resolves it with one direct get). The public/Stripe-facing shape (`toPublicCheckoutIntent`) carries -only the opaque intent id plus the pack offer — no board slug or +only the opaque intent id plus the pack offer; no board slug or internal board id may ever reach Stripe metadata, `client_reference_id` or URLs; the checkout slice must send the intent id and nothing board-identifying. `purchaseId` equals the intent id (one opaque identity per purchase); creating an intent -writes nothing to the ledger — the caller drives +writes nothing to the ledger; the caller drives `recordPurchase`/`closePurchase` with that id. Intent `expiresAt` is `createdAt + Policy.credits.intentTtlSeconds`, and the per-board pending cap (`Policy.credits.pendingIntentCapPerBoard`, @@ -552,7 +576,7 @@ against `checkoutIntentBoards/{boardId}` (`pendingIntentIds`), lazily expiring stale pending intents so an abandoned tab cannot block a board forever. `close`/`expire` free the cap slot. No routes, Stripe calls or board-loop wiring landed in -this slice — store selection arrived with PIN-149, the first +this slice; store selection arrived with PIN-149, the first slice to call these stores (below). Since PIN-178 the TTL only governs that cap slot. Intent status @@ -565,11 +589,11 @@ finalizes an `expired` intent, so a late grant lands on it. The old `resolvePending` ("still payable") call is gone: the webhook resolves an intent by id whatever its status. -## Stripe Checkout adapter + buy route (PIN-149) +## Stripe Checkout adapter + buy route -`StripeCreditCheckout` (`src/billing/stripe-checkout.ts`, +Introduced in PIN-149. `StripeCreditCheckout` (`src/billing/stripe-checkout.ts`, official `stripe` SDK, server-only) implements the additive -`CreditCheckout` port next to the plan-based `Checkout` port — +`CreditCheckout` port next to the plan-based `Checkout` port: packs are not Plus plans, so the plan port's shape stays untouched and keeps answering `unavailable` in stripe mode. The client is dependency-injected (tests run a fake; no network @@ -602,8 +626,8 @@ How a buy flows (`buyCredits`, `src/server/credit-checkout.ts`): fails, the intent and purchase are closed again so the cap slot is freed, and the caller gets a generic 503. -Session parameters (checked against the current Stripe docs — -Checkout Sessions create + Adaptive Pricing): +Session parameters (checked against the current Stripe docs: +Checkout Sessions create and Adaptive Pricing): - `mode: "payment"`, one line item at quantity 1 on the EUR base price. `expires_at` tracks the intent's expiry (floored @@ -613,14 +637,13 @@ Checkout Sessions create + Adaptive Pricing): freed. A session paid in the gap after the intent's TTL still grants (PIN-178); the webhook stays the grant authority. -- `adaptive_pricing: { enabled: true }` — Stripe presents the +- `adaptive_pricing: { enabled: true }`: Stripe presents the payer's local currency and handles conversion; the session still settles in EUR. This only takes effect on eligible sessions and follows the Dashboard's Adaptive Pricing - setting; if the account cannot use it (e.g. a Managed - Payments conflict, to verify when the mode is first flipped - on dev), the fallback is explicit `currency_options` on the - catalog prices — not built today. + setting; if the account cannot use it (for example a Managed + Payments conflict), the fallback is explicit `currency_options` + on the catalog prices, not built today. - **No `payment_method_types`.** That is the legacy static list; eligible methods are Dashboard-managed (dynamic payment methods) and naming types would override the Dashboard @@ -642,11 +665,12 @@ Hosted wiring in this slice was reference-only: dev referenced `STRIPE_SECRET_KEY` from Secret Manager in `apphosting.dev.yaml` but kept `PLUS_MODE=mock`, and prod referenced nothing, so no hosted backend took a Stripe payment -before webhook grants landed. PIN-153 flips dev (below). +before webhook grants landed. PIN-153 flipped dev (below). -## Stripe webhook (PIN-150) +## Stripe webhook -`POST /api/billing/stripe/webhook` (Node runtime) is the only +Introduced in PIN-150. `POST /api/billing/stripe/webhook` (Node +runtime) is the only grant authority for credit packs: it hands the RAW body + `Stripe-Signature` header to the SDK's `constructEvent` with `STRIPE_WEBHOOK_SECRET` (env name via `src/config.ts`; the dev secret exists since @@ -666,8 +690,8 @@ and every dead letter below. The service client (`src/billing/stripe-webhook.ts`), so tests verify real signatures with the SDK signer and fake only retrievals. -Events PIN-153 must register on the endpoint (snapshot -payloads, API version pinned in lockstep with the SDK) - +Events registered on the endpoint by PIN-153 (snapshot +payloads, API version pinned in lockstep with the SDK), exactly `STRIPE_WEBHOOK_EVENT_TYPES` in the service: - `checkout.session.completed` @@ -731,11 +755,11 @@ and dispute lifecycle pages): money, mirroring the makes claw: once the makes clawback has landed, the webhook clears `Board.plus` (`decidePlusRevocation`) when a purchase is taken all the way - back — the money taken back on it (cumulative settled refunds + back: the money taken back on it (cumulative settled refunds plus lost disputes, `takenBackEurCents`) reaching the pack price; partial refunds and partial losses never. The event that gets there claws the remainder and names the reason - (`full_refund`, or `dispute_lost` with its dispute id) — and + (`full_refund`, or `dispute_lost` with its dispute id), and leaves a `Board.plusRevocation` tombstone with the removed grant. If another granted purchase on the board is still live (its take-back below its price, even with its makes clawed @@ -758,7 +782,7 @@ and dispute lifecycle pages): actually-due revoke dead-letters `plus_revoke_unavailable`. - `async_payment_failed` + `expired` are the terminal events: they close the intent and its ledger purchase, - never grant; a purchase the ledger refuses to close (e.g. + never grant; a purchase the ledger refuses to close (for example partially pre-clawed) dead-letters for an operator. Stripe never reports one session both expired and paid, so a paid event after `expired` would dead-letter `dead_intent`. @@ -861,19 +885,20 @@ pack (PIN-181); a separate host plan stays unbuilt: the draft Refunds and disputes claw back makes and, when a purchase is taken all the way back, revoke its Plus too (PIN-242 above). -## Pool debit on makes (PIN-151) +## Pool debit on makes -`src/server/credit-pool.ts` is the one place the board loop -touches the ledger. Everything sits behind +Introduced in PIN-151. `src/server/credit-pool.ts` is the one +place the board loop touches the ledger. Everything sits behind **`Policy.credits.enabled`, default `false`** (deep-overridable via env `POLICY_OVERRIDES_JSON` or per board, like every other -credits key). While off, no ledger method is ever called — no +credits key). While off, no ledger method is ever called: no starter grants, no debits, no pool gate, no `credits` field in -the board view — so `AUTH_MODE=link-only` behaviour is exactly +the board view, so `AUTH_MODE=link-only` behaviour is exactly the pre-credit behaviour, and the untouched existing suites prove it. Flipping the flag off mid-life restores that at once. -No hosted backend enables it in this slice (activation is -PIN-153). While on but unwired (no store in deps — only +PIN-151 enabled it on no hosted backend; dev turned it on in +PIN-153 and prod in PIN-210 (both via `POLICY_OVERRIDES_JSON`). +While on but unwired (no store in deps, only possible in a test harness) the pool reads empty and debits refuse: fail closed, never gift a make. @@ -885,9 +910,9 @@ Since PIN-205 creation first decides the amount for the maker (the pool, or 0 past `starterBoardsPerMaker`; "Free creations rule" above) and stores it on the board; a lazy grant uses that stored amount, and the Policy default only for boards without it. -Every later credit touch — a board-view balance read (each -5-second poll), a photo preflight, a scene run or a make's -debit — first reads the account head with `getBalance`: one +Every later credit touch (a board-view balance read on each +5-second poll, a photo preflight, a scene run or a make's +debit) first reads the account head with `getBalance`: one `creditAccounts/{boardId}` doc read, no transaction, no `purchases`/`entries` read (PIN-179). Only a missing head or `starterGranted: false` (a board that predates the flag, or a @@ -908,7 +933,7 @@ PIN-151 photo row below applies again. | --- | --- | --- | | Photo (`DecomposeJob`) | only with `photosUseMakes`: the job finishing `ready` (one completed job per photo, any element count) | by default every photo; always failures/refusals, cap denies, tap-adds and tap naming on its working copy | | Scene/remix (`SceneJob`) | a ready scene recorded kind `ai` | refusals/failures, any collage (`AI_MOCK`, over-budget fallback, pool-empty lane) | -| Riders | — | suggestion chips, dictation: still only Policy-capped | +| Riders | none | suggestion chips, dictation: still only Policy-capped | **Debit points (exact seams).** Debit event ids are `debit:photo:{jobId}` / `debit:scene:{jobId}`, so a retry or @@ -926,14 +951,14 @@ claim gates already refuse a second run). stale sweep, PIN-64), exactly as with credits off. - Scene: inside `runSceneJob`, after the composed image is stored and before the terminal `finish` syncs a `ready` - Scene — the only state the image route serves. + Scene, the only state the image route serves. **Idempotent uploads + stale runners.** A photo POST may carry a client-minted `uploadId` that becomes the job id (docs/06): a replay returns the existing job before the pool preflight, so it can never reach this seam twice. The runner also checks its own -deadline (the summed Policy timeouts + `Policy.staleJobMarginMs` for its non-AI tail) *before* debiting — past it -the job fails `stale_running` with no debit — and every job-store +deadline (the summed Policy timeouts + `Policy.staleJobMarginMs` for its non-AI tail) *before* debiting: past it +the job fails `stale_running` with no debit. Every job-store write is compare-and-set on the expected status. If a debited runner nevertheless loses its guarded `ready` finish (the board-api stale sweep at bound + 2 x margin failed @@ -944,7 +969,7 @@ the make under the deterministic ledger event retries of the finish can never double-refund). If the request died after debiting (no live runner to refund), the sweep checks the ledger instead and keeps the paid job's candidates -confirmable — the board never pays for a result it cannot pin. +confirmable: the board never pays for a result it cannot pin. **Race rule.** The ledger debit is one atomic per-board read-modify-write, so two makes racing for the last credit @@ -957,7 +982,7 @@ Once a debit lands the make is paid: the ready outcome is recorded on the job first, and if the terminal write itself throws it is retried once instead of discarding or overwriting the result. A non-pool ledger failure at the seam fails the job -`credit_debit_failed` with the same discard/delete — failing +`credit_debit_failed` with the same discard/delete, failing closed without claiming the pool was empty. An exception in the photo run before the seam also discards landed candidates while credits are on (off, the keep-what-landed PIN-64 rule @@ -968,7 +993,7 @@ stands). photo. Scene: the existing free collage lane composes instead (the composer port is never called, the scene is honestly kind -`collage` at $0, nothing debits) — decided inside +`collage` at $0, nothing debits), decided inside `runSceneJob` before composing, exactly like the budget fallback it joins. The Policy daily caps and the PIN-61 AI budget gates keep applying in every mode; a pool-empty collage @@ -995,9 +1020,10 @@ concurrent first touches grant once; replay after reload) runs against the fake Firestore in `src/server/firestore-credit-ledger-store.test.ts`. -## Plus sheet + creations UI (PIN-152, PIN-182, PIN-183, PIN-231) +## Plus sheet + creations UI -The user-facing word for makes is **creations** (the code keeps +Introduced in PIN-152 and reworked in PIN-182, PIN-183 and +PIN-231. The user-facing word for makes is **creations** (the code keeps `makes` / `credits`). There is exactly one sheet that asks for money, the Board Plus sheet (`PlusSheet` in `src/ui/skin-picker.tsx`, mounted by `PlusFlow` in `src/app/g/[slug]/PlusFlow.tsx`); the @@ -1022,7 +1048,7 @@ announced (`aria-live="polite"`). quiet line under the title in the board-meta style, no card: "N creations left · Get Plus", where "Get Plus" is a text button that opens the Plus sheet. At zero: "Out of creations. New scenes - will be collages." plus the same button — the free collage lane, + will be collages." plus the same button: the free collage lane, still labelled `Collage` on the print. Once the board has Plus the button reads "Add creations" (the same label on every way into the sheet below). The button is left out while billing is @@ -1094,8 +1120,9 @@ announced (`aria-live="polite"`). Processing means "adding your creations…": the banner refetches the board a few times, and only a real rise in `availableMakes` (over the count kept from buy time) may - say "Your creations are in — N creations left." (plus "Plus is - on." once the refetched view has `plus.active`) — granting stays + say that the creations are in and how many are left (plus + "Plus is on." once the refetched view has `plus.active`; + `makes.added`, `makes.addedPlus`). Granting stays webhook-only (PIN-150) and the redirect alone never claims success. No rise after the refetches lands on a neutral "still waiting" line; cancelled/failed returns get one neutral line @@ -1197,9 +1224,11 @@ code), `src/server/my-boards.test.ts` and `src/app/me/library-parts.test.ts` hosted flip ships with this slice: `Policy.credits.enabled` stays default `false` and no hosted `PLUS_MODE` changes. -## Reconciliation + dev activation (PIN-153) +## Reconciliation + dev activation -`npm run billing:reconcile` (`scripts/billing-reconciliation.mjs`, +_Operators only: this section is for whoever runs the hosted service; a visitor can skip it._ + +Introduced in PIN-153. `npm run billing:reconcile` (`scripts/billing-reconciliation.mjs`, pure report logic in `src/domain/billing-reconciliation.ts`) is the operator safety net: a read-only join of paid Stripe Checkout Sessions (listed with the runtime key, by env name) @@ -1254,8 +1283,8 @@ The script is automation-ready, in three output modes. Human mode (operator terminal) keeps the lines above and ends with one parseable `RECONCILIATION_JSON:{...}` line. `--json` sends the human lines to stderr and prints only the full payload -(`{stripe, project, summary, report}`) on stdout — operator -terminal only, since the report carries ids. `--summary-only` is +(`{stripe, project, summary, report}`) on stdout (operator +terminal only, since the report carries ids). `--summary-only` is the public-log mode: stdout is only `{stripe, project, summary}`, stderr only the aggregate `billing reconciliation …`, `SUMMARY …` and `refunds-disputes …` lines, and an error prints the Stripe @@ -1268,7 +1297,7 @@ summary (`summarizeBillingReconciliation` in counts and booleans only, pinned by tests) carries `paidNoGrantCount`, `grantNoPaymentCount`, `deadLetterTotal`, `pendingPastTtlCount`, `anomalyCount`, `clean`, and -`needsAttention` — true exactly when paid-no-grant > 0 or +`needsAttention`, true exactly when paid-no-grant > 0 or `deadLetterTotal > 0`, the alert condition. Exit stays 1 for any non-clean report (a superset of the alert condition) or a runtime error, 0 clean, 2 usage/config. @@ -1277,15 +1306,16 @@ error, 0 clean, 2 usage/config. `--summary-only` daily (06:17 UTC; GitHub runs the cron from the default branch, `main`) and on `workflow_dispatch`. The repo is public, so its log and step summary are too: the step summary is -the heading `## Billing reconciliation () — exit ` and -the summary JSON only. For the rows behind a red run, run the +a heading naming the environment and the exit code (see the +`Reconcile (fail = alert)` step in the workflow) and the summary +JSON only. For the rows behind a red run, run the script in an operator terminal (command above). The job runs in GitHub Environment `reconcile-dev` or `reconcile-prod`: the dispatch input picks it, else repository variable `PINNED_RECONCILE_ENV` (`prod` selects `reconcile-prod`; unset or anything else is `reconcile-dev`; keep it unset until go-live). It -authenticates to Google Cloud by Workload Identity Federation — no -service-account key exists anywhere — and installs dependencies +authenticates to Google Cloud by Workload Identity Federation (no +service-account key exists anywhere) and installs dependencies with `npm ci --ignore-scripts` before that auth step, so no install-time code runs next to a credential. Every action is pinned to a full commit SHA. @@ -1296,7 +1326,7 @@ paste them into issues, PRs or logs): 1. **Environments.** Settings → Environments: create `reconcile-dev` and `reconcile-prod`. Restrict `reconcile-prod` deployment branches to `main` only (`reconcile-dev`: `main` and - `dev`). No required reviewers — they would hold the cron. + `dev`). No required reviewers: they would hold the cron. 2. **Per environment** (not at repository level): secret `PINNED_RECONCILE_STRIPE_READ_KEY` (restricted read key: `rk_test_` in dev, `rk_live_` in prod; Checkout Sessions: Read, @@ -1339,7 +1369,7 @@ gcloud iam service-accounts add-iam-policy-binding "$SA@$P.iam.gserviceaccount.c `` comes from `gcloud projects describe "$P" --format="value(projectNumber)"`. Once enabled, a non-clean report or an error fails the run and -lands in the step summary — that failed run is the dead-letter +lands in the step summary; that failed run is the dead-letter alert, so watch Actions notifications for the workflow and drain dead letters per the runbook above before calling a billing change done. Never paste secret values into the workflow, issues or PRs. @@ -1347,7 +1377,7 @@ done. Never paste secret values into the workflow, issues or PRs. **Refunds/disputes visibility (PIN-243).** Each run also lists Stripe refunds and disputes from the last 30 days (`refundsLast30Days`, `disputesLast30Days`, `openDisputes` in the -summary; informational only — a refund is not an anomaly and +summary; informational only: a refund is not an anomaly and never changes the exit code). The restricted read key in the PIN-210 table carries Charges and Refunds: Read and Payment Disputes: Read for this (the Dashboard's permission names; on the @@ -1401,12 +1431,14 @@ and backfills the mapping; the PIN-147 pre-grant freeze / pre-clawback semantics then apply unchanged. A dispute for a genuinely unknown charge still dead-letters. -## Going live on prod (PIN-210) +## Going live on prod + +_Operators only: this section is for whoever runs the hosted service; a visitor can skip it._ -Prod (pinneds.com) sells the Board Plus pack with **live** Stripe -only once the prod switch (merge order below) reaches `main`, after -both live secrets exist: App Hosting fails the build when a -referenced secret does not exist. The operator +Introduced in PIN-210. Prod (pinneds.com) sells the Board Plus +pack with **live** Stripe once the prod switch (merge order below) +is on `main` and both live secrets exist: App Hosting fails the +build when a referenced secret does not exist. The operator scripts default to sandbox. A live run needs a live key **and** `--live --i-understand-live`, touches only `moriacrew-pinned-prod` and `https://pinneds.com/api/billing/stripe/webhook`, and prints @@ -1465,32 +1497,33 @@ is the reference. `gcloud secrets describe STRIPE_SECRET_KEY --project "$P" --format="value(name)"`, same for `STRIPE_WEBHOOK_SECRET`. Say so on PIN-210. -**Merge order.** Live billing comes in two pieces (Robin, Oct 31): +**Merge order.** Live billing came in two pieces (decided Oct 31): -1. **This runbook and the operator scripts** (PIN-210, #182) merge - to `dev` before the Nov 3 freeze. They change nothing on prod or - dev: the scripts only run from an operator machine. -2. **The prod switch** is a separate config-only PR (PIN-210, "turn - on live billing on prod"). It adds one block to +1. **This runbook and the operator scripts** (PIN-210, #182) + merged to `dev` before the Nov 3 freeze. They change nothing on + prod or dev: the scripts only run from an operator machine. +2. **The prod switch** was a separate config-only PR (PIN-210, #203, + "turn on live billing on prod"). It added one block to `apphosting.prod.yaml`: `PLUS_MODE=stripe`, both secret references - and `POLICY_OVERRIDES_JSON={"credits":{"enabled":true,"starterMakes":20}}` - (judging-week value, Robin Nov 2: 20 free creations per new board, - back to 5 after judging; the dev backend ignores that file). Merge - it only after step 4. - If that happens before the freeze, it rides promotion #2. If not, - run a config-only promotion as soon as step 4 is done, even during - judging week (`docs/DEPLOY.md` §4 recipe, §8). `dev` is frozen by - then, so that promotion carries only the switch. + and `POLICY_OVERRIDES_JSON` with credits on and `starterMakes` 5; + that value was raised to 20 on Nov 2 for judging week (20 free + creations per new board, back to 5 after judging; the dev backend + ignores that file). It + was to merge only after step 4. It is on `main` now, so the + `apphosting.prod.yaml` block is the source of truth for prod; a + later config-only change goes through `docs/DEPLOY.md` section 4 + (promote) and section 8 (judging week). **Smoke after the rollout.** `GET https://pinneds.com/api/health` shows `plusMode: "stripe"`; a webhook POST with a bad `Stripe-Signature` answers 400; a new board shows 20 creations (the judging-week `starterMakes`). -Robin buys the €2.99 pack **on the demo board** with a real card -and keeps it (`docs/DEPLOY.md` §6.4: the demo board is a normal -board, and this makes it a paying customer's board). Checkout says -"Board Plus · 20 creations", the demo board shows Plus and 20 more -creations, and the endpoint's delivery in Workbench is 200. Refund +The operator buys the €2.99 pack **on the judges' board** with a +real card and keeps it (`docs/DEPLOY.md` section 6, step 4: the +judges' board is a normal board, and this makes it a paying +customer's board). Checkout says +"Board Plus · 20 creations", the judges' board shows Plus and 20 +more creations, and the endpoint's delivery in Workbench is 200. Refund check, on a throwaway board: buy the pack again, then refund it in the Dashboard (Payments → the payment → Refund, full). The refund events claw back the 20 creations and revoke the board's Plus @@ -1505,7 +1538,7 @@ session grants once billing is back, or refund it). The pool stays on; `"enabled":false` in the override stops it too. Stopping for good: disable the endpoint in Workbench. -**Go-live ops runbook (PIN-243; operator Dashboard steps — +**Go-live ops runbook (PIN-243; operator Dashboard steps, documented here, never executed by an agent).** Run in order around the prod-switch merge above; this is Checklist B of `docs/security-review-2026-11-01.md` made executable: @@ -1514,29 +1547,30 @@ around the prod-switch merge above; this is Checklist B of dev rollout, Stripe Dashboard (test mode) → Developers → Webhooks → the `dev.pinneds.com` endpoint → "Send test event" (`checkout.session.completed`): the delivery must be **200**, - not 400 (bad signature — Secret Manager + not 400 (bad signature: Secret Manager `STRIPE_WEBHOOK_SECRET` does not pair with the endpoint; roll the secret in Workbench and re-store it, PIN-153 section) and not 503 (billing off / config fail-closed; check - `GET /api/health` `plusMode` and the PIN-243/§F6 pairing + - livemode guards). Repeat against the live endpoint after the - prod rollout. Only this test delivery (or the live smoke - below) proves the pairing — Stripe never returns the secret. + `GET /api/health` `plusMode` and the pairing and live-mode + guards, finding F6 in `docs/security-review-2026-11-01.md`). + Repeat against the live endpoint after the prod rollout. Only + this test delivery (or the live smoke below) proves the + pairing: Stripe never returns the secret. 2. **Check delivery history for accumulated 503s.** Same endpoint page → delivery history: during the billing-off era the prod webhook answered 503, so look for a backlog of failed/pending deliveries. Stripe retries for up to three days; resend only events for real paid sessions once pairing is proven, and let test/noise deliveries expire. A fresh run of non-2xx after - go-live means grants are not landing — stop and reconcile. -3. **Live EUR 2.99 buy + refund smoke.** The "Smoke after the - rollout" paragraph above, unchanged: buy the pack on the demo - board with a real card and keep it; buy again on a throwaway - board and refund in full in the Dashboard (Payments → the - payment → Refund); confirm the 20 creations claw back, the + go-live means grants are not landing: stop and reconcile. +3. **Live €2.99 buy + refund smoke.** The "Smoke after the + rollout" paragraph above, unchanged: buy the pack on the + judges' board with a real card and keep it; buy again on a + throwaway board and refund in full in the Dashboard (Payments → + the payment → Refund); confirm the 20 creations claw back, the endpoint delivery is 200, and the live reconcile command there - exits 0 (`clean=true`, `needsAttention=false`). Since PIN-242 (stacked below) note the changed behaviour: - a full refund also revokes Plus - confirm that here too. + exits 0 (`clean=true`, `needsAttention=false`). Since PIN-242 a + full refund also revokes Plus: confirm that here too. 4. **Rollback rehearsal.** Set `PLUS_MODE=off` in `apphosting.prod.yaml` and roll out `main` (Rollback paragraph above): buying answers "unavailable", the webhook 503s and diff --git a/docs/16-ai-usage.md b/docs/16-ai-usage.md index cf1f8280..7d16a0f2 100644 --- a/docs/16-ai-usage.md +++ b/docs/16-ai-usage.md @@ -1,7 +1,10 @@ -# AI usage attribution (PIN-137) +# 16. AI usage and cost log -Every decorated AI call appends one `AiCallLog` (v2) record, -including budget/cap denials (`denied_*` outcomes). Fields: +This page covers what each AI call records (PIN-137 added the +attribution fields), how a call is priced, and the operator report +that rolls the log up. Every decorated AI call appends one +`AiCallLog` (v2) record, including budget and cap denials +(`denied_*` outcomes). Fields: `boardId` (slug, internal only), `participantId`, `userIdAtCall` (snapshot at call time, `null` for link-only, never backfilled), `sceneId`/`photoId`/`jobId` where the caller @@ -20,16 +23,16 @@ Oct 30): | Operation | Billing unit | Estimate | | --- | --- | --- | -| Spark (`nameElements`, `nameOne`, `suggestChips`) | tokens | $1.25 in / $4.25 out per 1M tokens | +| Muse Spark (`nameElements`, `nameOne`, `suggestChips`) | tokens | $1.25 in / $4.25 out per 1M tokens | | SAM 3.1 (`segment`) | image | $2.50 per 1000 images | | Muse Image (`composeScene`) | generated image | flat $0.01 | -| Muse Voice (`transcribe`) | audio second | $0.18 per hour | +| Muse Voice Transcribe (`transcribe`) | audio second | $0.18 per hour | A scene costs the flat Muse Image price, whatever its token counts (PIN-180). Before the call the gateway reserves $0.01 against the daily budget (so the $5 code default admits 500 -scenes, and prod's $50 during judging admits 5,000). After -it, the `AiCallLog` row is priced at $0.01 per generated image: +scenes, and prod's $50 a day, set in `apphosting.prod.yaml` for +judging week, admits 5,000). After the call, the `AiCallLog` row is priced at $0.01 per generated image: `images` on the row is how many images the reply held, so a reply with no image (a refusal) and a failed call are priced at $0. Input and output tokens stay on the row for @@ -37,16 +40,15 @@ attribution but never move the price. The scene job's own `costUsd` is the same $0.01 for an AI scene and $0 for a collage. -## SAM `store` flag (PIN-213) +## SAM `store` flag -SAM store flag: PIN-213, default off until probed. `Policy.samStore` -unset (the default) sends no `store` field on SAM `/v1/responses` -calls, exactly as before; `false` sends `store: false` on every -concept and tap (point) cut. Set it per board -(`policyOverrides.samStore`) for the one live probe, then globally -via `POLICY_OVERRIDES_JSON`. Muse Image compose already sends -`store: false` (`Policy.sceneStore`). The SAM lab route does not use -the flag. +PIN-213. `Policy.samStore` unset (the default) sends no `store` field +on SAM 3.1 `/v1/responses` calls; `false` sends `store: false` on +every concept and tap (point) cut, and `true` sends `store: true`. +The flag is meant to be set per board (`policyOverrides.samStore`) +for one live probe, then globally via `POLICY_OVERRIDES_JSON`. +Muse Image compose already sends `store: false` (`Policy.sceneStore`, +default false). The SAM lab route does not use the flag. ## Collage reason @@ -54,24 +56,26 @@ A scene that comes out as a collage records why, in `collageReason` on the scene and its job (also in the public scene and job payloads): -- `budget`: the daily AI budget, call limit or AI switch - closed the composer (job errors `budget`, `call_limit`, - `budget_off`). The board shows one line on the print: AI was - busy, so this one is a collage, and no creation was used. No - make is debited for a collage. -- `pool_empty`: the board has no makes left (PIN-151). Its - copy is the makes copy (PIN-152). +- `budget`: the daily or hourly AI budget, the call limit or the AI + switch closed the composer (job errors `budget`, `hourly_budget`, + `call_limit`, `budget_off`). The board shows one line on the + print: AI was busy, so this one is a collage, and no creation was + used. No creation is debited for a collage. +- `pool_empty`: the board has no creations left (PIN-151). Its + copy is the creations copy (PIN-152). - `mock`: offline demo mode (`AI_MOCK=1`). No extra line. Collages from before this field have no reason and show no line. A photo job the budget denied (the namer, or the cutter when no pin landed) ends `failed` with the same `errorCode` -(`budget`, `call_limit` or `budget_off`), and the add sheet -shows the budget line instead of the generic failure. +(`budget`, `hourly_budget`, `call_limit` or `budget_off`), and the +add sheet shows the budget line instead of the generic failure. ## Operator report +_Operators only: this section is for whoever runs the hosted service; a visitor can skip it._ + ```sh npm run usage:costs -- --from 2026-10-01 --to 2026-10-31 npm run usage:costs -- --days 7 @@ -87,8 +91,9 @@ Firestore TTL policies can only be created console-side. Rows carry `expireAtTs` (a Timestamp mirror of `expireAt`, written by `FirestoreGameRepository`) so the operator can enable a TTL policy on `aiCallLogs.expireAtTs` in the Firebase console or via -`gcloud firestore fields ttls update`. Until that policy exists, -rows are retained — code does not fake deletion. The same +`gcloud firestore fields ttls update` (`docs/DEPLOY.md` §6, item 1). +Until that policy exists, rows are retained; the code does not +fake deletion. The same console step applies to `starterAllowances.expireAtTs` (PIN-204, the free-creations-per-maker counters keyed by a hashed client address or user id, docs/15): with the policy on, a counter goes diff --git a/docs/17-showcase-sets.md b/docs/17-showcase-sets.md index 17374e64..7b07dc12 100644 --- a/docs/17-showcase-sets.md +++ b/docs/17-showcase-sets.md @@ -1,4 +1,4 @@ -# Landing showcase sets: how to make, add or change one +# 17. Landing showcase sets: how to make, add or change one The landing page shows one **showcase set** per visit (PIN-175): the hero sticker pile, then the three "How it works" pictures. This page @@ -6,17 +6,22 @@ is the working recipe for making a set, adding it to the landing and changing it later. The try-on and cat sets went through it on Oct 30 and 31, 2026. -The rule for every set (PIN-170): every photo is synthetic, and every -sticker and scene is made by this app's own pipeline. Spark names the -things, SAM 3.1 cuts them out and Muse Image composes the scene. After -that, files are only resized and cropped, never retouched by hand. +The rule for every set made with this recipe (PIN-170): every photo is +synthetic, and every sticker and scene is made by this app's own +pipeline. Muse Spark names the things, SAM 3.1 cuts them out and Muse +Image composes the scene. After that, files are only resized and +cropped, never retouched by hand. The older `classic` set predates the +recipe: its photo came from Higgsfield text-to-image, and its cut-outs +and scene are eval fixtures (`public/landing/README.md`). It is +synthetic too, and two of its cut-outs had a hole made transparent by +hand, which that README records. Related: - Spike decisions and evidence: [`eval/showcase/README.md`](../eval/showcase/README.md) (PIN-171). -- How the landing draws a set: `docs/14-design-system.md`, "Showcase - sets". +- How the landing draws a set: the "Showcase sets" entry in + [`docs/14-design-system.md`](14-design-system.md). - What each shipped file is made from: [`public/landing/README.md`](../public/landing/README.md). @@ -34,9 +39,38 @@ Related: | 8 | Check | tests, phone and desktop screenshots | the PR | | 9 | Record provenance | per-set table and prompts | `public/landing/README.md` | -Steps 2 to 6 run on scratch scripts today. PIN-172 adds the set -manifest (`eval/showcase/sets.json`) and PIN-173 turns the driver into -`scripts/showcase-build.mjs`. This page is the contract they follow. +Steps 2 to 6 run with one script, `scripts/showcase-build.mjs` +(PIN-173; helpers in `scripts/showcase/`, tests next to them): + +| Step | Command | +| --- | --- | +| 2 | `photos --live` | +| 3 | `serve [--live --env-file ]` | +| 4 | `plan `, then `run [set...]` | +| 5 | `run` writes `contact-sheet.html`; `sheet ` rebuilds it | +| 6 | `web ` | + +Run it as `node scripts/showcase-build.mjs ` or +`npm run showcase -- `; `--help` prints the options. + +- **Paths and env.** Every path and the env file come from an option or + an environment variable (`--work-dir` / `SHOWCASE_WORK_DIR`, + `--photos-dir` / `SHOWCASE_PHOTOS_DIR`, `--base-url` / + `SHOWCASE_BASE_URL`, `--env-file` / `SHOWCASE_ENV_FILE`). The work + folder defaults to `.showcase-work/`, which git ignores. Nothing + about the operator's machine is written into the repo. +- **Paid calls need a flag.** `photos` only plans until you pass + `--live`. `serve` starts a mock app (`AI_MOCK=1`, free) unless you + pass `--live`. `run` refuses a live app unless you pass `--live`. +- **Specs hold no local paths.** A friend's photo is a relative name + inside the photos folder (`cat/aye.webp`), so a spec can be + committed. `plan` and `run` take one set spec, or a manifest + `{ "sets": [ ... ] }` plus set ids (`scripts/showcase/spec.mjs`). + PIN-172's manifest is not in the repository. +- **Nothing secret in output.** Board slugs, session cookies and keys + are never printed, logged or written to `run.json`. + +This page stays the contract the script follows. ## 1. Write the brief @@ -99,12 +133,39 @@ returns 1280x1920 or 1920x1280 **WebP**, whatever name you save it under. Save it as `.webp`: the upload takes the picture's type from the file extension. +The `photos` command does this. Its items file has one entry per photo, +named relative to the photos folder: + +```json +[ + { "out": "cat/aye.webp", "size": "1536x1024", "prompt": "Casual smartphone photo of ..." } +] +``` + +```sh +node scripts/showcase-build.mjs photos items.json --env-file --live +``` + +- It makes one photo at a time and saves each under its real type + (WebP, whatever name you asked for). +- It skips a photo that already exists; `--force` makes it again. +- It counts every generated image in `photos-state.json` in the work + folder, against `--image-cap` (40 by default, PIN-172's cap), and + stops before a call that would pass the cap. Start the count from the + images made elsewhere with `--baseline N` on the first run. A call + whose picture cannot be fetched or is not an image still counts (it + was paid for), is reported as failed, and the next photo carries on. +- The state file also keeps each image's model, size, request id and + time, for the provenance table. +- Without `--live` it only prints the plan. The log shows status, + time and size, never the key, a prompt or an answer. + What cuts well, from the runs so far: - **Contrast.** Put a thing on a plain surface of another colour. A cream cable-knit scarf on a cream bench came out ragged. - **On its own.** Don't rest a thing on large furniture. With a scarf - on a green armchair, Spark named the armchair and dropped the scarf. + on a green armchair, Muse Spark named the armchair and dropped the scarf. - **Pairs give one sticker.** Tan suede boots came out as one boot. - **Big enough.** A person photo also yields small extras (tote, plant, sneakers). The tote cut-out was 82 px wide, too small for the @@ -118,9 +179,16 @@ What cuts well, from the runs so far: ## 3. Run the app locally with live models Run the worktree's app on `localhost:3000` with the operator's env -file loaded at start-up. For example, a small launcher outside the -repo calls `process.loadEnvFile()`, sets the overrides below and -starts `next dev`. +file loaded at start-up: + +```sh +node scripts/showcase-build.mjs serve --live --env-file +``` + +`serve` loads the env file, forces the overrides below, and starts +`next dev` listening on localhost only. Without `--live` it starts a +mock app (`AI_MOCK=1`): free, and its scenes are collages. Use that +for a dry run of the whole chain before any live run. | Variable | Value | Why | | --- | --- | --- | @@ -133,8 +201,12 @@ starts `next dev`. | `AI_DAILY_CALL_LIMIT` | `2000` | room for a full run | Also unset the Stripe variables, `DEMO_BOARD_SLUG` and -`FIRESTORE_EMULATOR_HOST`. Live calls run only on the operator's -machine. CI stays on `AI_MOCK=1`. +`FIRESTORE_EMULATOR_HOST` (`serve` does). A live server also gets +longer model timeouts through `POLICY_OVERRIDES_JSON` (Muse Spark +90 s, SAM 3.1 60 s, scene 180 s; a longer value in the env file wins; +`LIVE_TIMEOUTS_MS` in `scripts/showcase/serve.mjs`), and a +throwaway `SESSION_SECRET` when the env has none. Live calls run only on +the operator's machine. CI stays on `AI_MOCK=1`. Limits that matter on a local run: @@ -152,8 +224,14 @@ Limits that matter on a local run: ## 4. Drive one board per set -Drive the local app over its public boards API, one board per set. -These are the same calls `scripts/seed-demo-board.mjs` makes: +```sh +node scripts/showcase-build.mjs plan spec.json # no network: checks the spec and the photos +node scripts/showcase-build.mjs run spec.json cat --live # one set; --live only for a live app +``` + +`run` drives the local app over its public boards API, one board per +set, and sets run one after another. These are the same calls +`scripts/seed-demo-board.mjs` makes: 1. `POST /api/boards` with `{ title, themeId }` creates the board. Keep its slug in memory only. A board slug is the board's access @@ -176,19 +254,20 @@ These are the same calls `scripts/seed-demo-board.mjs` makes: - Download `GET /api/boards/{slug}/scenes/{id}/image`. Pick a scene's elements by a pattern on the label or description, not -by an exact label. Spark names the same thing differently from run to +by an exact label. Muse Spark names the same thing differently from run to run ("Smiling Woman", then "Selfie Woman"). -The spike's driver read a spec like this one, with photo paths -pointing into the work folder: +`run` reads a set spec like this one. Photo names are relative to the +photos folder, so the spec holds no local path: ```json { + "id": "cat", "title": "Showcase: cat (synthetic)", "friends": [ - { "name": "Aye", "photo": "/cat.webp" }, - { "name": "Ben", "photo": "/beanie.webp" }, - { "name": "Kim", "photo": "/skateboard.webp" } + { "name": "Aye", "photo": "cat/aye.webp" }, + { "name": "Ben", "photo": "cat/ben.webp" }, + { "name": "Kim", "photo": "cat/kim.webp" } ], "scenes": [ { @@ -199,20 +278,63 @@ pointing into the work folder: { "friend": "Ben", "match": "beanie|hat" }, { "friend": "Ben", "match": "heart|sunglass|glasses" }, { "friend": "Kim", "match": "skate|board" } - ] + ], + "names": ["cat", "beanie", "sunglasses", "skateboard"] } ] } ``` -It wrote the downloads and a run record next to them: status and time -per photo, labels, face flags, and each scene's status, kind and -prompt version. Keep all of it in the work folder. +The script checks the spec before it starts, so a mistake costs nothing: + +- 2 to 6 friends with made-up first names, and at most 3 scenes. +- A scene prompt is at most 500 characters and has 1 to 4 picks + (`Policy.pinsPerScene`), from at least 2 friends. +- The prompt must name every picked thing (each `match` must match + the prompt). +- **Every item the prompt names must be a picked pin.** List those + items in `names`: each must be in the prompt and match some pick. + Muse Image draws an unpinned item from the text alone (PIN-171). +- A scene runs only if every pick found a thing in the photos. A pick + prefers a label match to a description match, and one thing never + fills two picks. + +What `run` does and refuses: + +- It only talks to `localhost`, `127.0.0.1` or `[::1]`. A deployed app + needs `--allow-remote-base`. +- It reads `/api/health` first. A live app (`aiMock: false`) needs + `--live`, because the run then makes paid calls. +- It retries a failed photo once and logs each job's `errorCode`. A + refusal is not retried. It tries a scene up to `--scene-tries` times + (3) when it is refused, failed or came back as a collage. +- It counts every photo and scene job against `--max-jobs` (24), and + refuses to start if the sets need more than that at the least. +- It fails loudly (exit 1) on a scene that is refused or, on a live + app, a collage; a photo with no usable pin; a pick with no thing; + and a run over the cap. It still writes `run.json` and the contact + sheet, so the evidence is kept. After a failing set it stops + without starting the next. +- A set's folder `out//` that already exists is moved aside to + `out/-

pending`, then docs/20"). + ```sh + gcloud logging metrics create pinned_report_received --project "$P" \ + --description "Report & hold: a report was filed (docs/20)" \ + --log-filter 'resource.type="cloud_run_revision" AND resource.labels.service_name="pinned-web" AND jsonPayload.event="report.received"' + ``` + Test-fire once per project with a report on a throwaway board + (`docs/launch-ops-runbook.md` §3 rule: an alert nobody saw + arrive is not an alert). The backstop (every 6 h) + (`.github/workflows/report-sweep.yml`) needs its own identity: + its header lists the operator steps. - GCP billing budget (e.g. $25) with 50/90/100 % emails. Meta spend is not on GCP billing: run `GCLOUD_PROJECT=moriacrew-pinned-prod npm run usage:costs -- --days 1` @@ -289,7 +317,12 @@ the judges' board and kept (§6.4). Steps, smoke and rollback: pins, scenes since the last check; nobody but the recorded participants and the judges should appear), the demo template board (unchanged counts: 2 participants, its pins and 1 scene) and - a "See a board" click (lands on a fresh board), `usage:costs`. + a "See a board" click (lands on a fresh board), `usage:costs`, and + the reports queue: `npm run report:ops -- --project moriacrew-pinned-prod pending` + (an alert email means one just landed; decide each with `show`, + then `dismiss` or `takedown`, docs/20). The app already hid what + the report named, so the queue is never an emergency for the + content, only for the reporter waiting on an answer. - **Demo boards piling up or abused:** lower `demo.maxTotal` / `demo.maxPerIp` in `POLICY_OVERRIDES_JSON` (0 closes the demo; the landing then still shows the button, which answers "busy"), or diff --git a/docs/README.md b/docs/README.md index 444751d0..1d05eae2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,62 +1,81 @@ # Docs index -One line per document. Start with the repo [`README.md`](../README.md), then -[`11-studio.md`](11-studio.md) for the spec and [`19-meta-models.md`](19-meta-models.md) -for how each Meta model is used. Numbers 01, 02 and 05 are archived with the dropped game; -04 was removed on Day 1 (PIN-18). +One line per document, in reading order for a first-time visitor. Start with the repo +[`README.md`](../README.md), then [`19-meta-models.md`](19-meta-models.md) for how each +Meta model is used and [`11-studio.md`](11-studio.md) for the spec. Internal runbooks for +whoever runs the hosted service are grouped near the end; the archive holds the photo game +Pinneds started as. Numbers 01, 02 and 05 are archived with that game, 08 and 09 were +superseded by `14-design-system.md` and archived on Oct 31; 04 was removed before the +pivot. -## Current docs (`docs/`) +## Start here + +| Doc | What it covers | +| --- | --- | +| [`README.md`](../README.md) | What Pinneds is, how the Meta models power it, the architecture, how to run it, known limits | +| [`19-meta-models.md`](19-meta-models.md) | Each Meta model: prompt and version, Policy keys, inputs, fallbacks, cost, evidence, what leaves the server | +| [`11-studio.md`](11-studio.md) | Studio spec: product, decisions, Policy, ports, jobs, data model, storage, UX, test plan, issue map | +| [`adr/003-studio-architecture.md`](adr/003-studio-architecture.md) | ADR-003: Policy as data, capability ports and decorators, jobs, Element/Pin split, stateless remix | +| [`build-log.md`](build-log.md) | Who built what: each coding agent's PRs with dates, the evidence for each claim, and what the record does not show | + +## Product and features | Doc | What it covers | | --- | --- | -| [`03-ai-sdlc.md`](03-ai-sdlc.md) | How work flows: Linear issue → coding agent → PR → review, for a solo developer | | [`06-photo-intake.md`](06-photo-intake.md) | Photo intake: checks, EXIF stripping, the short-lived working copy, what is never stored | -| [`07-sam-lab.md`](07-sam-lab.md) | SAM 3.1 lab notes and measurements (`/lab/sam`, dev only) | -| docs/08 (UX principles, superseded by docs/14) | First minimal, icon-first UX rules | -| docs/09 (first design system, superseded by docs/14) | Per-board looks and themes from the game era | -| [`10-custom-domains.md`](10-custom-domains.md) | pinneds.com and dev.pinneds.com domain setup | -| [`11-studio.md`](11-studio.md) | Studio spec: product, decisions, Policy, ports, jobs, data model, storage, UX, test plan | -| [`12-submission.md`](12-submission.md) | Hackathon submission packet and video plan | -| [`13-auth.md`](13-auth.md) | Optional Google sign-in (`AUTH_MODE`), claim flow, My boards and My pins | | [`14-design-system.md`](14-design-system.md) | The Pinneds design system: look, words, UI kit, landing showcase sets | -| [`15-board-plus.md`](15-board-plus.md) | Board Plus and creations: `PLUS_MODE`, Stripe Checkout, webhook grants, ledger, runbook | +| [`13-auth.md`](13-auth.md) | Optional Google sign-in (`AUTH_MODE`), claim flow, My boards and My pins, delete and rename | +| [`15-board-plus.md`](15-board-plus.md) | Board Plus and creations: `PLUS_MODE`, Stripe Checkout, webhook grants, ledger | | [`16-ai-usage.md`](16-ai-usage.md) | AI call log, cost estimate per operation, collage reasons, usage report | -| [`17-showcase-sets.md`](17-showcase-sets.md) | Recipe for making a landing showcase set with the app's own pipeline | | [`18-i18n.md`](18-i18n.md) | UI languages: how a request picks one, `UI_LOCALES`, adding strings, translation runs | -| [`19-meta-models.md`](19-meta-models.md) | Each Meta model: prompt and version, Policy keys, inputs, fallbacks, cost, evidence | -| [`20-abuse-runbook.md`](20-abuse-runbook.md) | Abuse reports and illegal content: storage hold, quarantine, library copies, single-element hold, NCMEC CyberTipline, takedown clock | -| [`DEPLOY.md`](DEPLOY.md) | Prod runbook and env matrix (lands with PIN-209) | -| [`launch-ops-runbook.md`](launch-ops-runbook.md) | Launch ops: hourly AI sub-cap, GCP budget alerts, soft-kill drill, demo-board protection (PIN-240) | -| [`build-log.md`](build-log.md) | Who built what: each coding agent's PRs with dates, the evidence for each claim, and what the record does not show (PIN-248) | +| [`17-showcase-sets.md`](17-showcase-sets.md) | Recipe for making a landing showcase set with the app's own pipeline | +| [`adr/004-report-and-hold.md`](adr/004-report-and-hold.md) | ADR-004: reports hold first (reversibly), the operator decides, timed takedown only for face-flagged likeness reports, no model as judge | +| [`12-submission.md`](12-submission.md) | Hackathon submission packet: pitch, evidence table, video plan, form answers | -## Decisions (`docs/adr/`) +ADR-001 (hosting) and ADR-002 (no friction) still stand; they live in the archived +[`archive/02-architecture.md`](archive/02-architecture.md). + +## Measurements and lab notes | Doc | What it covers | | --- | --- | -| [`adr/003-studio-architecture.md`](adr/003-studio-architecture.md) | ADR-003: Policy as data, capability ports and decorators, jobs, Element/Pin split, stateless remix | +| [`../eval/studio-findings.md`](../eval/studio-findings.md) | Measured results: fidelity, naming, speed, cost, refusals, live runs | +| [`../eval/showcase/README.md`](../eval/showcase/README.md) | Showcase-set spike decisions and evidence | +| [`07-sam-lab.md`](07-sam-lab.md) | SAM 3.1 lab notes and measurements (`/lab/sam`, dev only) | -ADR-001 (hosting) and ADR-002 (no friction) still stand; they live in the archived -`archive/02-architecture.md`. +## Internal runbooks (operators only) + +For whoever runs the hosted service. A visitor can skip them. + +| Doc | What it covers | +| --- | --- | +| [`DEPLOY.md`](DEPLOY.md) | Environments, env matrix, pre-flight, promotion to `main`, smoke, judging week | +| [`launch-ops-runbook.md`](launch-ops-runbook.md) | Launch ops: hourly AI sub-cap, GCP budget alerts, soft-kill drill, demo-board protection | +| [`20-abuse-runbook.md`](20-abuse-runbook.md) | Abuse reports and illegal content: the in-app report and its automatic hold, the operator's decide script, the manual hold path, NCMEC CyberTipline, takedown clock | +| [`security-review-2026-11-01.md`](security-review-2026-11-01.md) | Pre-launch security review (Nov 1) with the status of each finding and the fixes that closed it | +| [`10-custom-domains.md`](10-custom-domains.md) | pinneds.com and dev.pinneds.com domain setup | +| [`03-ai-sdlc.md`](03-ai-sdlc.md) | How work flows: Linear issue, coding agent, PR, review, for a solo developer | ## Archive (`docs/archive/`, history only) -The photo game Pinneds started as was dropped on Oct 28. Don't apply these docs. +The photo game Pinneds started as was dropped on Oct 28, 2026. These pages are kept for +the record; do not apply them. | Doc | What it covers | | --- | --- | | [`archive/01-requirements.md`](archive/01-requirements.md) | Day-1 requirements for the game (FR/NFR IDs) | | [`archive/02-architecture.md`](archive/02-architecture.md) | Game architecture, including ADR-001 and ADR-002 | | [`archive/05-research-synthesis.md`](archive/05-research-synthesis.md) | Day-1 research synthesis | +| [`archive/08-ux-principles.md`](archive/08-ux-principles.md) | First minimal, icon-first UX rules (superseded by `14-design-system.md`) | +| [`archive/09-design-system.md`](archive/09-design-system.md) | The first-generation Studio look and its per-board themes (superseded by `14-design-system.md`) | ## Key files outside `docs/` | File | What it covers | | --- | --- | +| [`SECURITY.md`](../SECURITY.md) | Public-repo guardrails: never-commit list, scanners, leak runbook, repository settings | | [`AGENTS.md`](../AGENTS.md) | Rules for coding agents working in this repo | -| [`SECURITY.md`](../SECURITY.md) | Public-repo guardrails: never-commit list, scanners, leak runbook | | [`prompts/README.md`](../prompts/README.md) | Prompt contracts and the list of versioned templates | | [`eval/README.md`](../eval/README.md) | Rules for evaluation fixtures (synthetic only, source noted) | -| [`eval/studio-findings.md`](../eval/studio-findings.md) | Measured results: fidelity, naming, speed, cost, refusals, live runs | -| [`eval/showcase/README.md`](../eval/showcase/README.md) | Showcase-set spike decisions and evidence | | [`public/landing/README.md`](../public/landing/README.md) | Where each landing image comes from | | [`.env.example`](../.env.example) | Every environment variable, with safe local defaults | diff --git a/docs/adr/003-studio-architecture.md b/docs/adr/003-studio-architecture.md index 9025be16..77235562 100644 --- a/docs/adr/003-studio-architecture.md +++ b/docs/adr/003-studio-architecture.md @@ -1,9 +1,14 @@ -# ADR-003: Studio architecture — Policy as data, capability ports, jobs (PIN-49, Oct 28 2026) +# ADR-003: Studio architecture (Policy as data, capability ports, jobs) -_Status: Accepted. Supersedes the game architecture in +_Status: Accepted Oct 28, 2026 (PIN-49). Supersedes the game architecture in `docs/archive/02` (ADR-001 hosting and ADR-002 no-friction stand). Spec: `docs/11-studio.md` §5._ +_Since then (Nov 4, 2026): three more ports (`ChipSuggester`, `TapNamer`, +`SpeechTranscriber`) run under the same chain, and the `tmp/` lifecycle rule in the +repo is 1 day (A6; the operator applies it per bucket, `docs/DEPLOY.md` §6). Current +state: `docs/19-meta-models.md`._ + ## Context Studio replaces the game loop (persona, judging, reveal) with a diff --git a/docs/adr/004-report-and-hold.md b/docs/adr/004-report-and-hold.md new file mode 100644 index 00000000..13e980cd --- /dev/null +++ b/docs/adr/004-report-and-hold.md @@ -0,0 +1,89 @@ +# ADR-004: Reports hold first, decide second, delete on a timer only for faces + +_Status: Accepted Nov 4, 2026 (PIN-266). Extends ADR-003. Runbook: +`docs/20-abuse-runbook.md`. Policy row: `docs/11-studio.md` §5.1 +(`report.*`, `takedownHours`)._ + +## Context + +"Report this board" (PIN-239) is a one-tap `mailto:` to the legal +contact whose body carries only an opaque board reference. On +Nov 4 2026 a visitor sent one by mistake: the mail held the template +and nothing else. There is no reason to pick, no confirmation, no +in-app record, and everything after receipt is manual (find the +board, hold Storage, tombstone, find library copies). The pieces the +runbook uses by hand already exist in code: scene `hidden`, element +refs prefixed `held:` (never served, never deleted, never reach a +model), the board tombstone, `Element.faceFlag` set by Muse Spark at +upload, and Storage temporary holds. + +Three automations were on the table: take a board offline or delete +its assets whenever a reported picture contains faces; let a model +judge the reported content; delete as soon as anyone on the board +reports. Hosting-service notice-and-action duties (DSA Art. 16 and +17) apply regardless of company size; Art. 19 exempts small +providers only from the online-platform duties. Legal wording stays +with counsel. + +## Decision + +1. **Reports are in-app records.** `POST /api/boards//report` + writes a `reports/{id}` document with a scope (element, scene, + board), one reason (`likeness`, `inappropriate`, `illegal`, + `ownership`, `other` with a message), the reporter kind + (participant when the board cookie is valid, else visitor) and + what was held. The reporter sees a receipt with the reference. The + `mailto:` goes; the contact address stays as plain text. +2. **Hold at report time, reversibly.** The request applies the hold + itself (no queue, like the demo sweep): the element's refs get + `held:` and the scenes using it `hidden`; a whole-board hold does + that for every element and scene and sets `Board.reviewAt`, which + blocks uploads, scenes and tap-adds (423). A report records + exactly what it held so `dismiss` can restore it. +3. **Decision table.** Element or scene: held for any reporter and + reason. Whole board: held when the reporter is a participant or + the reason is `illegal`; an anonymous visitor's whole-board report + with another reason is queued and alerted, nothing hidden (anyone + with a link can join and report, so one click must not blank a + friend group's board). `illegal` adds Storage temporary holds on + the board's folders. +4. **Delete on a timer.** _Amended 2026-11-04 (PIN-278): the + operator does not watch the queue by default._ Every report that + holds something sets `deleteAt = now + takedownHours`; the backstop + tombstones it if nobody decided by then. Content reported as + `illegal` is tombstoned too, but its Storage temporary holds + survive the takedown, so the bytes are preserved for the runbook's + §7 until `release-holds`. A queued-only report (nothing hidden) has + no deadline. _Before:_ only a `likeness` report on a face-flagged + element had a deadline, and `illegal` was never deleted by the app. +5. **No model as judge.** Suspected illegal content never leaves the + project (the runbook's "nothing reads image bytes" holds for the + automation too), a model verdict is not a defensible takedown + ground, and the signal we rely on is already computed at upload + (`faceFlag`, screen and document flags) or at generation (provider + refusals). +6. **Alert through Monitoring, not mail.** The route logs one + structured line `report.received` with the reference, reason, + scope, severity and hold counts, never the slug or the message. A + log-based alert policy on the existing email channel delivers it. + The operator decides with `scripts/report-decide.mjs` (`--dismiss` + restores, `--takedown` follows the runbook); a signed-in operator + page can come later. +7. **Limits are Policy.** `report.perIp`, `report.maxBytes`, + `report.messageMaxChars`, `report.sweepBatch`; the deadline is the + existing `takedownHours` promised on `/terms`. + +## Consequences + +- A mistaken report hides at most the thing it names, and `dismiss` + puts it back; today's accidental mail would have produced a queued + record and an alert, nothing hidden. +- The reporter's message is stored for the operator but never logged + or mailed; the alert carries the reference only, so the board link + never spreads. +- Prod gets this with promotion #4 after judging week; it lands on + `dev` first. +- Not done here: email acknowledgment to the reporter (needs an SMTP + provider and a new secret), an operator UI, and any automated + detection of illegal material (partner-gated hash matching; the + human path in `docs/20` remains). diff --git a/docs/build-log.md b/docs/build-log.md index 522e4ba6..3aeb7c60 100644 --- a/docs/build-log.md +++ b/docs/build-log.md @@ -1,6 +1,7 @@ # Build log: who built Pinneds, and how we know -_As of Nov 2, 2026, 19:20 UTC (`dev` at `a90cd7b`; PIN-248). Times are +_As of Nov 4, 2026, 16:00 UTC (`dev` at `d958f29`; PIN-248, counts +refreshed by PIN-274). Times are US Eastern (ET) from GitHub's merge times. This log backs every "how it was built" line in the [`README`](../README.md), the submission packet ([`12-submission.md`](12-submission.md)), the in-app @@ -32,7 +33,7 @@ request ([`03-ai-sdlc.md`](03-ai-sdlc.md)): | Muse Code built the Stripe billing (PIN-146–153) | Backed | W | | Claude Code refined parts of it | Backed | F, W, L | | Claude Code carried it to prod | Backed | F, W, L | -| Cursor Bugbot reviewed every PR | Backed: 215 of 215 merged PRs | GitHub check runs | +| Cursor Bugbot reviewed every PR | Backed: 240 of 240 merged PRs | GitHub check runs | ## Sources @@ -44,7 +45,7 @@ Strongest first: names Muse Code as the executor of PIN-48–57. - **W** (operator-held): the two agents worked in separate local working copies of the repo, and each copy's git reflog records the - branches that got commits there (read Nov 2). The Muse Code copy was + branches that got commits there (read Nov 2 and Nov 4). The Muse Code copy was cloned Oct 26 at 10:43 ET and made the first commit after GitHub's initial one. The Claude Code copy was cloned Oct 30 at 06:44 ET; Claude Code sessions outside that copy are covered by F. @@ -234,22 +235,23 @@ All F or W, and L where the issue is labelled: - Live Stripe path for the operator scripts and runbook: #182 (PIN-210). The prod billing switch: #203 (PIN-210, Oct 31 23:04 ET). - The prod landing's demo link: #224 (PIN-246). -- Promotion #2 (#226, merged Nov 2 14:16 ET) was cut in the Claude Code - copy (W). Promotions #16 (Oct 26) and #31 (Oct 31) carry `dev` and +- Promotions #2 (#226, merged Nov 2 14:16 ET) and #3 (#236, Nov 3 + 14:22 ET) were cut in the Claude Code copy (W). Promotions #16 (Oct + 26) and #31 (Oct 31) carry `dev` and are not attributed to one agent; agents never merge to `main` without the operator ([`AGENTS.md`](../AGENTS.md)). ### 7. Cursor Bugbot reviewed every PR From GitHub's check runs on the final commit of each merged PR (read -Nov 2, 19:30 UTC): +Nov 2, 19:30 UTC, and again Nov 4, 16:20 UTC): -- All 215 merged PRs have a "Cursor Bugbot" check run: 209 finished +- All 240 merged PRs have a "Cursor Bugbot" check run: 234 finished clean and 6 finished neutral (findings posted on the final commit). -- Bugbot opened 241 review threads across 85 of those PRs. It opened +- Bugbot opened 264 review threads across 94 of those PRs. It opened every review thread on them. - CI (`check`) and `gitleaks (full history)` passed on the final commit - of all 215. + of all 240. - Cursor wrote no code: no PR came from a `cursor/` branch, and no Pinned issue carries the `executor/cursor` label. @@ -257,31 +259,33 @@ Nov 2, 19:30 UTC): | Day (ET) | Muse Code | Claude Code | | --- | --- | --- | -| Oct 26 | Repo guardrails before kickoff; scaffold, Firebase, App Hosting, live models on dev (#1–#18; #11 not shown, #16 is a promotion) | — | -| Oct 27 – Oct 28 morning | The photo-game prototype (#19–#39, not #31) and domains (#40–#42) | — | +| Oct 26 | Repo guardrails before kickoff; scaffold, Firebase, App Hosting, live models on dev (#1–#18; #11 not shown, #16 is a promotion) | None | +| Oct 27 – Oct 28 morning | The photo-game prototype (#19–#39, not #31) and domains (#40–#42) | None | | Oct 28 14:56 – Oct 29 06:26 | The Studio core (#43–#71) | Drafted the hand-off before the run | | Oct 29 | Sign-in (#72–#79); hardening (#80, #84, #88, #92, #93) | Design system and UI rebuild (#81–#107 without Muse Code's, to Oct 30 06:14) | | Oct 30 | Tap naming, photo choice, dictation (#100, #103, #106); AI call log (#108, #110); Stripe billing (#117–#132) | Scene viewer, delete, rename, board delete (§5) | | Oct 31 | Upload pipeline: idempotent uploads, stale-job sweep, multi-photo queue, SAM confidence (#145–#149) | Billing refinements, languages, showcase sets, prod prep (#136–#208 without #145–#149) | | Nov 1 | Pre-launch security review and first fixes (PIN-238–243) | Submission brief (#214), `/models` page (#215) | -| Nov 2 | Security fixes merged (#216, #218–#222) | Follow-up fixes on that stack; #223–#225; promotion #2 (#226) | +| Nov 2 | Security fixes merged (#216, #218–#222) | Follow-up fixes on that stack; #223–#225; promotion #2 (#226); runbook updates (#228) | +| Nov 3 | Phone-layout fixes (#240–#241, 23:07–23:15 ET): named in Linear only, see "What the record does not show" | Submission packet and build log (#227); demo-board docs (#229); per-visitor demo boards (#230–#235, #238); showcase script (#138); smoke runbook (#239); promotion #3 (#236) | +| Nov 4 | Judging-week landing (#243–#246, 01:02–03:32 ET): named in Linear only, see "What the record does not show" | Report & hold (#247–#252) | The photo game Pinneds started as was dropped on Oct 28; #47 deleted its code ([`archive/`](archive/)). ## Counts -Merged PRs as of Nov 2, 2026, 19:20 UTC. A PR count is a rough measure: +Merged PRs as of Nov 4, 2026, 16:00 UTC. A PR count is a rough measure: PR sizes vary from a one-line fix to a full slice. | Source | Merged PRs | | --- | --- | | Muse Code copy only (W, with D or L where they exist) | 98 | -| Claude Code (F, or W in the Claude Code copy) | 106 | +| Claude Code (F, or W in the Claude Code copy) | 124 | | Both copies (the security fixes) | 6 | -| Promotions to `main` (#16, #31, #226) | 3 | -| No agent evidence (#11, #134) | 2 | -| **Total** | **215** | +| Promotions to `main` (#16, #31, #226, #236) | 4 | +| No agent evidence (#11, #134, #240–#241, #243–#246) | 8 | +| **Total** | **240** | ## What the record does not show @@ -295,6 +299,11 @@ PR sizes vary from a one-line fix to a full slice. - **#134** (PIN-169, Oct 30 17:40 ET): storage clean-up when a board is deleted. Its commits are in neither copy. Its sibling issues PIN-167 and PIN-168 (#133, #135) were done in the Claude Code copy. +- **#240–#241, #243–#246** (PIN-261–265, Nov 3 23:07 – Nov 4 03:32 ET): + phone-layout fixes and the judging-week landing. Their branches are in + neither copy's reflog on the operator's machine. The Linear close + comments name Muse Code, which this log does not count as evidence + (see Sources). - **Gaps in W.** Reflogs live on the operator's machine, expire after a while and disappear with a removed worktree. "Not shown" means not shown, not "done by someone else". @@ -303,7 +312,7 @@ PR sizes vary from a one-line fix to a full slice. ## Appendix: every merged PR by source Ranges list merged PRs only. Numbers closed without merging (#3, #4, -#75, #217) or still open (#138, #209–#213) are skipped. +#75, #209–#213, #217, #237) or still open (#242, #253) are skipped. - **Muse Code copy only (98):** #1–#10, #12–#15, #17–#30, #32–#80, #84, #88, #92–#93, #95, #100, #103, #106, #108, #110, #117, #119, #121, @@ -311,13 +320,13 @@ Ranges list merged PRs only. Numbers closed without merging (#3, #4, - **Claude Code footer (78):** #81–#83, #85–#87, #89–#91, #94, #96–#99, #101–#102, #104–#105, #107, #143–#144, #150–#155, #157–#167, #169–#208, #223. -- **Claude Code copy, no footer (28):** #109, #111–#116, #118, #120, +- **Claude Code copy, no footer (46):** #109, #111–#116, #118, #120, #122, #124, #129–#131, #133, #135–#142, #156, #168, #214–#215, - #224–#225. Promotion #2 (#226) was also cut in this copy and is - counted under promotions. + #224–#225, #227–#235, #238–#239, #247–#252. Promotions #2 (#226) and + #3 (#236) were also cut in this copy and are counted under promotions. - **Both copies (6):** #216, #218–#222. -- **Promotions (3):** #16, #31, #226. -- **No agent evidence (2):** #11, #134. +- **Promotions (4):** #16, #31, #226, #236. +- **No agent evidence (8):** #11, #134, #240–#241, #243–#246. ## Re-running this log diff --git a/docs/launch-ops-runbook.md b/docs/launch-ops-runbook.md index 83e321bb..2fa7cd43 100644 --- a/docs/launch-ops-runbook.md +++ b/docs/launch-ops-runbook.md @@ -1,4 +1,6 @@ -# Launch ops runbook (PIN-240) +# Launch ops runbook + +_Operators only: this page is for whoever runs the hosted service. A visitor can skip it and start at the [docs index](README.md)._ _Owner: Robin (operator). Launch guardrails for judging week: the hourly AI sub-cap, GCP budget alerts, the soft-kill drill, diff --git a/docs/security-review-2026-11-01.md b/docs/security-review-2026-11-01.md index 91108126..eba41e59 100644 --- a/docs/security-review-2026-11-01.md +++ b/docs/security-review-2026-11-01.md @@ -1,4 +1,6 @@ -# Pinned — Pre-launch security review (dev / main), 2026-11-01 +# Pre-launch security review (dev and main), 2026-11-01 + +_Operators only: the review as written on Nov 1, 2026, kept for the record with the status of each finding; the build log cites it as evidence. Start at the [docs index](README.md) for the product._ > **Status, Nov 2, 2026.** The findings below were fixed on `dev` in PIN-238–243 > (#216, #218–#222) and reached prod with promotion #2 (#226): F1 report link, upload diff --git a/eval/showcase/README.md b/eval/showcase/README.md index c05d2924..09090bd2 100644 --- a/eval/showcase/README.md +++ b/eval/showcase/README.md @@ -137,7 +137,7 @@ metadata (PIN-174). - Original photos, raw cut-outs and scenes stay outside git. - This note and the contact sheet are the only spike files in the repo. -- The scratch driver becomes `scripts/showcase-build.mjs` in PIN-173. +- The scratch driver became `scripts/showcase-build.mjs` (PIN-173). - The living step-by-step recipe for making, adding or changing a set is `docs/17-showcase-sets.md` (PIN-175); this note stays as the spike's record. diff --git a/eval/studio-findings.md b/eval/studio-findings.md index 88a82f9d..b43642dd 100644 --- a/eval/studio-findings.md +++ b/eval/studio-findings.md @@ -1,4 +1,4 @@ -# Studio Slice 0 — spike findings (PIN-50) +# Studio slice 0: spike findings _Generated by `scripts/studio-spike.mjs` from live local runs on synthetic images. Per A1 this file holds the findings; `docs/11` is not edited. Thresholds: docs/11 s8. Raw redacted data: `eval/fixtures/studio/fixtures.json` + `call-log.json`. Contact sheets: `eval/contact-sheets/`._ diff --git a/package.json b/package.json index a514bed3..c867fc39 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "smoke:meta": "node scripts/meta-smoke.mjs", "spike:studio": "node scripts/studio-spike.mjs", "seed:demo": "node scripts/seed-demo-board.mjs", + "showcase": "node scripts/showcase-build.mjs", "seed:stripe-catalog": "node scripts/seed-stripe-catalog.mjs", "usage:report": "node scripts/usage-report.mjs", "usage:costs": "node scripts/usage-costs.mjs", @@ -26,6 +27,7 @@ "i18n:check": "node scripts/i18n-check.mjs", "billing:reconcile": "node scripts/billing-reconciliation.mjs", "report:find-ref": "node scripts/find-report-ref.mjs", + "report:ops": "node scripts/ops-run.mjs scripts/report-ops.ts --", "billing:webhook-endpoint": "node scripts/ensure-stripe-webhook-endpoint.mjs" }, "dependencies": { diff --git a/scripts/ops-run.mjs b/scripts/ops-run.mjs new file mode 100644 index 00000000..0b33d43d --- /dev/null +++ b/scripts/ops-run.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Run a TypeScript operator script against the app's real server code + * (PIN-272). The server modules are `server-only` and import each other + * without extensions, so plain Node cannot load them; this bundles the + * entry with esbuild (already in node_modules through vitest/vite) + * under the `react-server` condition, which turns `server-only` into + * its empty module, keeps node_modules external, and runs the result. + * + * node scripts/ops-run.mjs scripts/report-ops.ts -- "; + record.failures = ["boom"]; + const html = renderSheet(record); + expect(html).not.toContain("