From a0252680d348fcaa90d2d1ea3bf1d68a0a94108f Mon Sep 17 00:00:00 2001 From: ArtaXerxes-oss Date: Wed, 21 Oct 2026 08:39:01 +0100 Subject: [PATCH] docs(spikes): add batch 550-599 design dossiers and verified blockers Covers refundable settlement (550), real-time live updates (551), collaborative canvas (552), and WebGL artifact viewer (553). Each doc answers the five spike questions, maps all 16-20 affected files, defines replay/idempotency, 409/412/428/429 taxonomy, migration/rollback, tests, and acceptance evidence. Also records pre-existing baseline hazards that block implementation: re-callable initialize, immediate 95/5 split, broken signal-store merge, missing chamber modules/viewerSignatureShort, placeholder CRDT files, and dual CSPs. No runtime routes or flags were added; the proposed gates remain design-only. --- PROJECT_ARCHITECTURE.md | 8 + docs/TECHNICAL.md | 7 + docs/spikes/550-refund-settlement-findings.md | 359 ++++++++++++++++++ docs/spikes/551-live-updates-findings.md | 266 +++++++++++++ .../552-collaborative-canvas-findings.md | 255 +++++++++++++ .../553-webgl-artifact-viewer-findings.md | 243 ++++++++++++ docs/spikes/SPIKE_INDEX_550_599.md | 87 +++++ 7 files changed, 1225 insertions(+) create mode 100644 docs/spikes/550-refund-settlement-findings.md create mode 100644 docs/spikes/551-live-updates-findings.md create mode 100644 docs/spikes/552-collaborative-canvas-findings.md create mode 100644 docs/spikes/553-webgl-artifact-viewer-findings.md create mode 100644 docs/spikes/SPIKE_INDEX_550_599.md diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index ad906764..35fa5ba8 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -185,6 +185,14 @@ is a fixed-size digest of the canonical payload, keeping it under wallet Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. +> **Proposed spike batch 550–599 is design-only.** The four spike docs and their +> proposed gates (`phase-550` refunds, `phase-551` live updates, `phase-552` +> collaborative canvas, `phase-553` WebGL viewer) are indexed in +> [`docs/spikes/SPIKE_INDEX_550_599.md`](docs/spikes/SPIKE_INDEX_550_599.md). +> None of those flags is registered in `lib/feature-flags.ts` yet; a flag is +> added only when there is a route or behavior to gate. See each doc for its +> rollback row and acceptance evidence. + ## 11) Change-management rules - Architectural changes require updates to: diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index aa025ff5..cc144c2d 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -308,6 +308,13 @@ NEXT_PUBLIC_FEATURE_PHASE_140=1 Rollback: unset the var or set `0` and restart. No ledger migration to revert; off-chain stores remain but are ignored when flag off. See `PROJECT_ARCHITECTURE.md` §10. +**Proposed, not registered:** spike batch 550–599 (refundable settlement, live +updates, collaborative canvas, WebGL viewer) is documented in +[`docs/spikes/SPIKE_INDEX_550_599.md`](./spikes/SPIKE_INDEX_550_599.md). The +proposed names `phase-550`–`phase-553` are intentionally absent from +`lib/feature-flags.ts` until a route or behavior exists to gate; each spike doc +carries its own rollback and acceptance plan. + --- ## 10. Security and Operations diff --git a/docs/spikes/550-refund-settlement-findings.md b/docs/spikes/550-refund-settlement-findings.md new file mode 100644 index 00000000..92a1e836 --- /dev/null +++ b/docs/spikes/550-refund-settlement-findings.md @@ -0,0 +1,359 @@ +# Spike 550 — Refundable Settlement for Irreversible Forge Payments + +Status: **proposed spike / research complete, not implemented**. +Batch: 550–599. Files in scope: 16. Effort: 4–8 weeks for one DRI. + +## Verdict first + +The brief is directionally right but understates the blocker. There is **no +refund path anywhere in the repository**, and — more importantly — for creator +collections (`collection_id > 0`) a refund is **structurally unfundable**: +`settle` forwards 95% to the creator and 5% to the treasury in the same +transaction, so the contract holds zero of that payment. + +Two preconditions must land before any refund endpoint is written: + +1. **`initialize` is re-callable.** `contracts/phase-protocol/src/lib.rs:430-460` + has no initialized guard. An attacker can call `initialize` with themselves as + admin/token/treasury and then call `upgrade` (`:464-472`) to replace the WASM + while preserving storage. Every admin-gated refund is unsafe until this is + fixed. +2. **Custody must change for new collections.** Either hold the 95/5 split until + a dispute window closes, or refund from a pre-funded treasury reserve with + explicit creator/treasury consent. "Debit creator + treasury proportionally" + is not implementable against already-transferred funds: the contract has no + allowance/approval path to pull funds back, no admin transfer/burn entrypoint, + and no contract-level clawback. (Classic-asset clawback exists only when the + issuer enabled it; this design does not assume that flag.) + +The honest recommendation is: **do not ship a refund button on the current +contract**. Ship the contract custody change first, behind `phase-550`, with the +API and UI following. + +## What the brief claimed vs. what the code actually does + +| Claim | Verified reality | +|---|---| +| "95/5 disbursed same tx, NFT minted" | True for `collection_id > 0`; see `lib.rs:738-760` (split) and `:775-827` (mint/energy). For `collection_id == 0` the full amount stays in the contract and no split runs. | +| "No refund entry, no `RefundedAmount`, no `AlreadyRefunded`/`RefundExceedsSettled`" | True. `PaymentRecord` has only `invoice_id`, `amount`, `token`, `payer`, `settled`, `timestamp` (`lib.rs:56-64`). `PhaseError` has no refund code (`:10-28`). | +| "No NFT burn-or-return policy" | True. There is no `burn` and no admin clawback. `transfer_phase_nft` requires the current owner's auth (`lib.rs:1088-1090`). | +| "Verifier treats refunded tx hash as valid payment" | True today, but for a different reason than stated: the verifier never reads `PaymentRecord`, never checks the invoice, and never asks whether a refund exists. It checks transaction success, source, contract, function `settle`, args 0–2 (payer, token, amount) — `lib/forge/payment-verifier.ts:107-140`. A refunded settle still passes. | +| "Escrow settlement 2-of-3 + `DISPUTE_WINDOW` is the closest primitive to reuse" | It is a **type/validation module only** (`lib/escrow-settlement.ts`), with no store, route, or execution function. Its only importer is `lib/env-validation.ts:693` (an audit probe). It is a vocabulary to borrow, not a mechanism to wire. | +| "Marketplace offer accept/refund path exists as a seam" | Offers are SQLite bookkeeping; accepting flips status and inserts an optional royalty row (`app/api/market/[id]/offers/[offer_id]/route.ts:47-67`). No funds move, no NFT moves, no escrow exists. `OfferStatus` is `pending | accepted | rejected | expired` (`lib/market-store.ts:13`). | +| "Custodian-release in-flight guard is a double-execution template" | The route has a process-local `Set` guard, not a durable lock. On serverless it resets on every cold start / instance. It is a shape to copy, not a correctness proof. Its Bearer check is presence-only (`app/api/phase-nft/custodian-release/route.ts:106-113`). | +| "Refunds credit user debiting creator+treasury proportionally (reverse reserve/commit/release)" | Not possible on already-split funds. The distributor ledger's `reserve`/`commit`/`release` (`lib/distributor-ledger.ts:169-278`) is a refill reservation, not a payment ledger; `commitRefill` does not decrement `balance_stroops`, and `setLedgerBalance` has no production caller. Contributor shares are attribution only (`lib/contributor-ledger.ts`: `shareBps`, no balances/payouts). | +| "Contributor sub-splits complicate clawback ordering" | True, but there is nothing to claw back: contributor entries never paid out. A refund design must not pretend this ledger is financial. | +| "Used-settlements store prevents replay" | It is a JSON `txHash -> ISO timestamp` map (`lib/settlement-store.ts:60-72`), marked **before** prompt validation and generation (`app/api/forge-agent/route.ts:98-119`). It has no payer/amount/invoice/collection/refund fields, no TTL, no unmark, and it bypasses `serverDataJsonPath` (`lib/settlement-store.ts:4`). The official x402 path can produce an empty receipt (`payment-verifier.ts:175-181`), skipping the store entirely. | + +## The settlement lifecycle as it actually runs + +``` +browser + POST /api/forge-agent {prompt} + -> 402 challenge + build settle(payer, token, REQUIRED_AMOUNT, invoiceId, 0) + Freighter signs -> submit -> poll SUCCESS + POST /api/forge-agent {prompt, settlementTxHash} + -> verifyPhaseSettleTxOnChain (args 0..2 only) + -> isSettlementUsed / markSettlementUsedIfUnused(txHash) + -> runForgePipeline -> mintNftStep (stub: returns {txHash:null}) +``` + +On-chain, the same `settle` call: + +``` +check token == AuthorizedToken lib.rs:703-711 +reject if PaymentRecord.settled lib.rs:713-719 +user.require_auth() lib.rs:721 +amount >= collection price / global price lib.rs:723-726 +transfer user -> contract lib.rs:736 +if collection_id > 0: 95% -> creator, 5% -> treasury lib.rs:738-760 +write PaymentRecord{settled:true} lib.rs:762-773 +mint NFT on first phase, else add energy lib.rs:775-827 +emit x402_settled(invoice_id, amount) lib.rs:829-832 +``` + +There is no transaction hash in `PaymentRecord`, no `token_id`, and no +`collection_id`. The event carries only `(invoice_id, amount)`, so an indexer +cannot map a payment to the NFT it minted. + +## Q1 — Minimal closed loop that proves the integration + +The loop must prove both money and asset movement, because a refund that leaves +the NFT with the buyer is a free-mint exploit. + +``` +fund (Friendbot) -> trustline -> settle(collection) -> mint + -> refund request -> approval -> NFT return/burn -> refund transfer + -> index refund event -> UI shows REFUNDED and hides the artifact +``` + +Minimum viable version (phase 1, only safe on new collections): + +1. **Contract**: `settle` holds 100% in the contract for + `SETTLEMENT_HOLD_SECONDS` (proposed 7 days) and records a claimable balance + for creator/treasury; `claim_settlement(invoice_id)` pays them after the hold. +2. **Contract**: `refund(invoice_id)` requires `payer.require_auth()` **and** + `admin.require_auth()` (two-party), checks the hold has not been claimed, + transfers the NFT from payer to the issuer/custodian (or burns it if a burn + entrypoint is added), then transfers the held amount back to the payer, then + writes `RefundRecord`. +3. **Server**: `POST /api/refunds` (request) and `POST /api/refunds/[id]/execute` + (approve+submit), both `phase-550` gated, both with an idempotency key. +4. **Verifier**: `verifyPhaseSettleTxOnChain` must read the invoice's refund + state and return `false` when `refunded` is true. It must also parse + `invoice_id` (arg 3) and the collection id (arg 4). +5. **Indexer/UI**: consume `x402_refunded` (new event), flip the artifact to + `REFUNDED`, exclude it from explore aggregates, and make the forge retry + fail with a normalized error. + +### Which file can fail the loop independently + +| Stage | File | Independent failure | +|---|---|---| +| Hold/split | `contracts/phase-protocol/src/lib.rs` | Immediate split leaves nothing to refund; no hold/claim entrypoint. | +| Mint linkage | `contracts/phase-protocol/src/lib.rs` | `PaymentRecord` has no `token_id`/`collection_id`; refund cannot identify the NFT. | +| Verification | `lib/forge/payment-verifier.ts` | Accepts a refunded tx; ignores invoice/collection; `>= REQUIRED_AMOUNT` instead of exact price. | +| Off-chain replay | `lib/settlement-store.ts` | txHash-only key, marked before work, ephemeral path, no refund state. | +| Execution auth | `app/api/phase-nft/custodian-release/route.ts` | Presence-only Bearer, process-local guard: duplicate execution across instances. | +| Refund API | (new route) | A money-out hole if it trusts a body wallet or a client-supplied amount. | +| Indexing | `lib/phase-protocol.ts` | Strict event verifier expects topic `settle` while the contract emits `x402_settled`; tuple parser swaps invoice/amount (`:1412-1426`, `:1465`). | +| Marketplace | `lib/market-store.ts`, offers routes | An accepted offer on a refunded listing would still pay the seller. | +| Display | `app/api/explore/route.ts`, `app/collection/[id]/page.tsx` | Refunded artifacts remain in public listings/metadata. | + +## Q2 — Where replay/idempotency lives for every write + +| Write | Idempotency key | Store / mechanism | Double-submit after timeout | +|---|---|---|---| +| `settle` | `invoice_id` | On-chain `DataKey::X402Payment(invoice_id)` + `settled` (`lib.rs:713-719`) | Second tx reverts `SettlementFailed`; no transfer. **But invoice ids are random 0–999999 (`app/forge/page.tsx:591`), so collisions across users are possible.** | +| Forge generation | `txHash` | `used-settlements.json` (`lib/settlement-store.ts:60-72`) | Second request gets 409; the first may already have consumed the hash before failing. No release. | +| x402 verify | `invoice` (not txHash) | Read-only check (`app/api/x402/verify/route.ts:46-55`) | Repeated verify returns 200; it never marks. Different key from forge-agent. | +| Viewer grant | `jti` | SQLite `viewer_jti` PK + `INSERT ... ON CONFLICT DO NOTHING` (`lib/viewer-authorization.ts:70-116`) | Second presentation rejected atomically. This is the model refunds should copy. | +| Signal edit | `If-Match` version | SQLite `UPDATE ... WHERE id=? AND version=?` (`lib/signal-store.ts:950-981`) | 409 with `current_version`. | +| Refund request | `Idempotency-Key` + `UNIQUE(invoice_id)` | New SQLite `refunds` table | Same key returns the existing record (200); different key for a non-terminal refund returns 409; terminal refund returns 200 with `state`/`tx_hash`. | +| Refund execute | `refund_id` + DB `state=executing` + on-chain `RefundRecord` | SQLite CAS + contract flag | Re-query `get_refund_status(invoice_id)`. If executed, mark done; if not found and the previous attempt is older than 2× `maxDuration`, retry. Contract rejects `AlreadyRefunded`, so a duplicate submit is safe. | + +### Proposed SQL + +```sql +CREATE TABLE IF NOT EXISTS refunds ( + refund_id TEXT PRIMARY KEY, + invoice_id INTEGER NOT NULL, + payer TEXT NOT NULL, + token TEXT NOT NULL, + amount_stroops TEXT NOT NULL, -- i128 as decimal string + collection_id INTEGER NOT NULL, + nft_token_id TEXT, + nft_disposition TEXT NOT NULL DEFAULT 'pending' + CHECK (nft_disposition IN ('pending','returned','burned','retained_denied')), + state TEXT NOT NULL DEFAULT 'requested' + CHECK (state IN ('requested','approved','executing','executed','failed','rejected')), + version INTEGER NOT NULL DEFAULT 0, + idempotency_key TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + tx_hash TEXT, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(invoice_id), + UNIQUE(idempotency_key) +); + +CREATE INDEX IF NOT EXISTS idx_refunds_state ON refunds(state, updated_at); +``` + +State transitions use `BEGIN IMMEDIATE` and +`UPDATE refunds SET state=?, version=version+1 WHERE refund_id=? AND version=?`; +`changes !== 1` means a concurrent writer won and the caller must re-read. + +### Contract storage shape (do not mutate `PaymentRecord`) + +Adding a field to an existing `#[contracttype]` struct changes its XDR schema and +can make already-persisted values fail to decode. Add a **new** key and struct +instead, and append enum variants rather than reordering them: + +```rust +#[contracttype] +pub struct RefundRecord { + pub invoice_id: u32, + pub payer: Address, + pub token: Address, + pub amount: i128, + pub collection_id: u64, + pub nft_token_id: Option, + pub nft_disposition: NftDisposition, + pub state: RefundState, + pub requested_at: u64, + pub executed_at: u64, +} + +#[contracttype] +pub enum NftDisposition { Pending, Returned, Burned, RetainedDenied } + +#[contracttype] +pub enum RefundState { Requested, Approved, Executed } + +// append only +pub enum DataKey { + // ... existing variants ... + Refund(u32), // invoice_id -> RefundRecord + SettlementV2(u32), // invoice_id -> SettlementRecordV2 (token_id, collection_id, tx hash) +} +``` + +The important part is ordering: + +```rust +pub fn refund(env: Env, payer: Address, invoice_id: u32) -> Result { + admin_require(&env)?; // stored Admin + payer.require_auth(); // payer must consent + let payment: PaymentRecord = ...; // settled == true + if has_refund(&env, invoice_id) { return Err(PhaseError::AlreadyRefunded); } + + let settlement: SettlementRecordV2 = ...; // token_id, collection_id + if nft_owner(&env, settlement.token_id) != payer { + return Err(PhaseError::NftNotReturnable); // transferred to a third party + } + if settlement_claimed(&env, invoice_id) { + return Err(PhaseError::RefundExceedsSettled); // creator/treasury already paid + } + + // checks-effects-interactions: write state first, then move value. + write_refund(&env, RefundRecord { state: Executed, nft_disposition: Returned, ... }); + transfer_nft_from_payer_to_issuer(&env, payer, settlement.token_id); + token::Client::new(&env, &payment.token) + .transfer(&env.current_contract_address(), &payer, &payment.amount); + + env.events().publish( + (Symbol::new(&env, "x402_refunded"), payer), + (invoice_id, payment.amount, settlement.token_id), + ); + Ok(true) +} +``` + +Do **not** write a refund that calls an external token before recording state. +Soroban reverts the whole invocation on error, but the indexer and the off-chain +reconciler still need a deterministic event/state to replay. + +## Q3 — Contract-ID drift, issuer rotation, gateway failover + +| Failure | Fence | What it must prove | +|---|---|---| +| Contract ID drift | `get_config()` via `lib/phase-protocol.ts:623-643`; `normalizeContractC` in `payment-verifier.ts` | The token returned by the contract equals the token in the transaction args. A refund against an old contract ID fails closed, not "best effort". | +| Token/issuer rotation | `resolvePhaserLiqClassicAsset()` (`lib/stellar.ts:93`) + `get_config` token | The refunded token is the same SAC the settlement used. Persist `token` in `RefundRecord`; never infer it at refund time. | +| Metadata/CID drift | `token_uri?c=` / `lib/cid-verification.ts`; `lib/phase-nft-metadata-build.ts` `prioritizeGatewaysByHealth` (`:59-80`) | A refunded token's metadata is either removed or marked `refunded`; a stale gateway copy cannot keep displaying a live artifact. | +| RPC failover | `app/api/soroban-rpc/route.ts` circuit breaker + `MAX_IN_FLIGHT=12` | The refund reconciler treats RPC failure as `unknown`, never as `not executed`, and retries with the same idempotency key. | +| Treasury/creator address rotation | New `get_refund_config()` (or `get_config` extension) | Approval signers and payout addresses are snapshotted on the refund record; a mid-flight rotation cannot redirect funds. | +| Admin key compromise | 2-of-N approval (proposed), `PHASE_GOVERNANCE_SIGNERS` (currently signature is **optional and unverified** in `lib/faucet-deny-list.ts:65-69,192-225`) | Refund execution requires distinct approver signatures; do not reuse the faucet veto path as-is. | + +## Q4 — Status-code taxonomy + +| Code | When | Body / headers | +|---|---|---| +| `400 REFUND_INVALID` | Malformed invoice, NFT id, or amount; address format. | `{ error, code }` | +| `401 REFUND_UNAUTHORIZED` | Missing/invalid payer signature or approval signature. | `WWW-Authenticate: Signature` | +| `403 REFUND_FORBIDDEN` | Payer mismatch, signer not in governance set, collection not refundable. | `{ code: "REFUND_FORBIDDEN", reason }` | +| `404 REFUND_NOT_FOUND` | Unknown refund id (GET). | | +| `409 REFUND_ALREADY_EXISTS` | A refund for this invoice exists with a different idempotency key. | `{ refund_id, state, current_version }` | +| `409 REFUND_STATE_CONFLICT` | Invalid transition (e.g. execute a rejected refund). | `{ state, allowed }` | +| `412 REFUND_PRECONDITION_FAILED` | Hold not expired, settlement not final, NFT transferred, creator/treasury already claimed, or `If-Match` version stale. | `{ code, required, current }` + `ETag` | +| `428 REFUND_VERSION_REQUIRED` | Execute/approve without `If-Match`. | `ETag: ""` | +| `429 REFUND_RATE_LIMITED` | Per-wallet/IP refund request cap. | `Retry-After: `, `X-RateLimit-*` | +| `503 REFUND_RPC_UNAVAILABLE` | Soroban/RPC unavailable or circuit open. | `Retry-After` | +| `504 REFUND_TIMEOUT` | Submission accepted but confirmation not observed. | `Retry-After`; state remains `executing` | + +The UI must never auto-retry a refund POST without the same `Idempotency-Key`. +On `412`, show the current state and a "reload" action rather than replaying the +user's input. On `409`, fetch the existing refund and render it. On `429/503`, +disable the button and display the `Retry-After` countdown. + +## Q5 — Migration and rollback + +### Contract + +| Option | Migration | Rollback | Verdict | +|---|---|---|---| +| In-place WASM upgrade | `upgrade(new_wasm_hash)` preserves storage; append `DataKey` variants only. | Upgrade back to the previous WASM; new keys are ignored. | Preferred once `initialize` is guarded. | +| Redeploy new instance | New contract ID; old NFTs/payments stranded on the old instance. | Re-point env back. | Only for a clean testnet reset, never for mainnet. | + +Do not change the discriminant order of `DataKey` or `PhaseError`. Append. + +### Off-chain + +- Additive SQLite tables (`refunds`, optional `refund_events`) via + `CREATE TABLE IF NOT EXISTS`; no destructive `ALTER`. +- `used-settlements.json` is left in place as historical data; the new refund + ledger is authoritative for refund state. Do not delete the sidecar while a + rollback is possible. +- On flag-off, the refund route returns 404, the verifier keeps its legacy + behavior, and existing refund rows are inert. The contract hold (if already + deployed) remains on-chain — flag-off does **not** un-hold funds. That is why + the custody change and the flag must ship together. + +### NFT policy + +| Option | Buyer keeps NFT? | Exploit | Recommendation | +|---|---|---|---| +| Burn on refund | No | Requires a new admin burn; must handle already-transferred tokens. | Only with payer auth + owner check; never unilateral. | +| Return to issuer/custodian | No | Requires payer auth; fails if the NFT was transferred. | **Recommended** for phase 1. | +| Leave with buyer | Yes | Buy → refund → keep NFT = free mint. | Only as an explicitly loss-bearing treasury policy, with deny-list and no repeat refunds. | + +## File-by-file invariant map + +| File | Change or ruling-out | Invariant it owns | +|---|---|---| +| `contracts/phase-protocol/src/lib.rs` | Add `initialize` guard, `RefundRecord`/`DataKey::Refund`, `refund`, `get_refund_status`, `x402_refunded`, refund errors, hold/claim if adopted. | The only atomic money+asset transition. | +| `lib/forge/payment-verifier.ts` | Parse invoice/collection; reject refunded; exact amount. | A paid hash is not a valid payment after refund. | +| `lib/escrow-settlement.ts` | Promote to a real hold store **or** rule out as dead vocabulary. | 2-of-3/DISPUTE_WINDOW semantics. | +| `app/api/x402/verify/route.ts` | Refund-aware; same key as forge-agent. | Verify is read-only but must not bless a refunded invoice. | +| `lib/settlement-store.ts` | Move to `serverDataJsonPath`; add status/payer/amount; or replace with SQLite ledger. | Replay key scope and durability. | +| `lib/phase-protocol.ts` | Add `buildRefundTransaction`, `readPhaseRefundStatus`; fix event topic/parser. | Client/chain contract. | +| `app/api/market/[id]/offers/route.ts` | Reject offers on refunded/held listings. | No new money against a refunded artifact. | +| `app/api/market/[id]/offers/[offer_id]/route.ts` | `refunded` terminal state; no payout. | Marketplace cannot outrun the refund. | +| `lib/market-store.ts` | Add `refunded` to `OfferStatus`/`ListingStatus` (append; migrate reads). | Terminal-state vocabulary. | +| `lib/distributor-ledger.ts` | Add `refundHold`/`releaseRefund`; fix `commitRefill` balance accounting before reuse. | Reserve/commit/release is not a refund ledger today. | +| `lib/contributor-ledger.ts` | Explicitly rule out clawback; attribution only. | Do not invent balances that do not exist. | +| `lib/x402-dead-letter.ts` | Quarantine failed refund executions; operator replay. | Poison refund attempts do not loop. | +| `lib/security-counters.ts` | Closed labels: `refund_requested`, `refund_executed`, `refund_rejected`, `refund_replay_blocked`, `refund_rpc_unknown`. | Observability without PII. | +| `lib/faucet-deny-list.ts` | Refund-farming deny-list; **verify** the governance signature. | Abuse response; currently signature is optional. | +| `app/api/phase-nft/custodian-release/route.ts` | Replace process-local Set with durable idempotency; verify Bearer signature. | Double-execution guard template. | +| `docs/AGENT_PAYMENTS.md` | Document refund policy, corrected 402/403/409 errors, and the fact that `settle` mints. | Agent-facing contract. | + +## Proposed flag + +`phase-550` — `NEXT_PUBLIC_FEATURE_PHASE_550` / `FEATURE_PHASE_550`. + +Rollback: unset/set `0`, restart. Refund routes return 404; verifier ignores +refund state; existing `refunds` rows and on-chain `RefundRecord`s remain inert. +**Do not roll back the contract hold independently of the flag**; they must be +deployed as one unit. + +## Tests and acceptance evidence + +Failing-first tests: + +- Contract: settle → refund happy path; double refund → `AlreadyRefunded`; + refund after NFT transfer → `NftNotReturnable`; refund after creator claim → + `RefundExceedsSettled`; `initialize` twice → `AlreadyInitialized`; `upgrade` + by non-admin → unauthorized. +- Off-chain: concurrent refund requests with the same/different idempotency key; + stale `If-Match` → 412; timeout → state `executing` then reconcile; verifier + rejects a refunded settle; marketplace cannot accept an offer on a refunded + listing. +- Abuse: self-refund by non-payer; forged governance signature; refund-farming + wallet denied; replay of the same execution tx. +- Testnet proof: Friendbot → trustline → settle (record tx hash + contract ID) + → refund (tx hash) → index event → UI shows `REFUNDED`. No hardcoded IDs; env + and docs synchronized. + +## Risks / non-goals / estimate + +- **Risk:** changing custody breaks instant-payout UX. Make the hold a + collection-level setting and show "pending" honestly. +- **Risk:** a refund that does not return the NFT is a free mint. Do not ship it. +- **Risk:** `initialize` re-entry turns any admin-gated refund into an attack + path. Fix it first. +- **Non-goal:** mainnet, production facilitator keys, shared-DB cutover. +- **Estimate:** 1–2 w research (this doc + failing tests), 2–4 w contract/API/UI, + 1–2 w hardening. Longer if contract custody is contested by product. diff --git a/docs/spikes/551-live-updates-findings.md b/docs/spikes/551-live-updates-findings.md new file mode 100644 index 00000000..8732fec9 --- /dev/null +++ b/docs/spikes/551-live-updates-findings.md @@ -0,0 +1,266 @@ +# Spike 551 — Real-Time Live Updates for Signals, Notifications, Market and Explore + +Status: **proposed spike / research complete, not implemented**. +Batch: 550–599. Files in scope: 20. Effort: 3–6 weeks. + +## Verdict first + +SSE is the right transport for this deployment; WebSockets are not. Vercel's +Node route handlers can stream a `ReadableStream` for the life of +`maxDuration`, but they cannot hold an upgrade socket. The hard part is not the +stream — it is **wallet-scoped authorization on a transport that cannot send +headers** and **cross-instance fanout**, because serverless instances share no +memory. + +The spike should be split: + +1. **Phase 1 (safe):** same-origin SSE for public channels + (`signals:global`, `market:public`) + a signed, HttpOnly cookie for private + channels. In-process hub only, explicitly documented as single-instance. +2. **Phase 2 (correct):** an external fanout adapter (Ably/Pusher, or a hosted + Redis with a streaming bridge) so every instance publishes to the same bus. + The server secret stays server-side; the browser gets a scoped token, never + `MERCURY_JWT` or `PHASE_ADMIN_KEY`. +3. **Phase 3 (optional):** presence, typing, live cursors. Not required for the + feed. + +## What the brief claimed vs. what the code actually does + +| Claim | Verified reality | +|---|---| +| "30s polling (notifications-modal)" | True: `POLL_INTERVAL_MS = 30_000` (`components/notifications-modal.tsx:53`), `setInterval` at `:267`. There is no in-flight guard, so slow requests can overlap. | +| "cache:no-store refetch (explore, signals)" | Half true. Explore uses `cache: "no-store"` (`app/explore/page.tsx:61`). Signals do **not** poll or force no-store; they fetch on mount/filter/load-more only (`app/signals/page.tsx:368-400`). Signal detail is `force-dynamic` RSC with no live refresh. | +| "Mercury webhook-only index" | False. Mercury is queried pull-style over REST (`lib/mercury-classic.ts`, `app/api/nft-index/route.ts`). `app/api/nft-index/subscribe/route.ts` registers a webhook and verifies reachability, but there is **no Mercury receiver route**, no secret persistence, and no HMAC verification. The only inbound webhook is NanoBanana, which skips verification if `NANOBANANA_WEBHOOK_SECRET` is unset. | +| "No WebSocket/EventSource/SSE in package.json/lib" | True. Zero `EventSource`, `new WebSocket`, `text/event-stream`, `socket.io`, `ws`, or realtime dependencies. `ReadableStream` appears only in a world export download and a test. | +| "Only Yjs HTTP-poll CRDT" | False. There is **no `yjs` dependency, no Yjs import, no `signal-crdt.ts`, no CRDT route**. The only collaborative conflict mechanism is `expected_version` / `expected_vector_clock` on `POST /api/world` (`app/api/world/route.ts:137-177`, `lib/world-conflict.ts`), and its save is not a CAS. | +| "Explore already needs monotonic loadSeq" | True. `loadSeq` guards only the newest response (`app/explore/page.tsx:53-75`); requests are not aborted. Pagination is offset/slice, so live inserts shift page boundaries. | +| "MAX_PER_WALLET=50 cap vs live burst" | True: `MAX_PER_WALLET = 50` (`lib/notification-store.ts:38`, `:237-240`). The notification store has **no lock and no atomic rename**; concurrent fanout can lose writes (`:206-241`). | +| "60/min/IP" | Only a helper; it is never called from the Soroban POST handler (`app/api/soroban-rpc/route.ts:134-151`). Active controls are `MAX_IN_FLIGHT=12`, a 5-slot Horizon bulkhead, and 20/60s per-wallet reactions. Signals/notifications/market have no rate limits. | + +### Baseline blockers + +- `lib/signal-store.ts` is a broken JSON/SQLite merge on this HEAD + (`:154-177` references undeclared `conditions`, unimported `getDb`, redeclares + `items`). `GET /api/signals` cannot run. A repair exists on + `origin/fix/signal-store-sqlite-merge-repair` (`936e42a`) but is unmerged. +- Write auth is not ready for wallet-scoped live channels: + - upvotes send the wallet address as the "signature" and the server never + verifies it (`app/api/signals/[id]/route.ts:65-88`, + `app/signals/page.tsx:433-437`); + - poll votes never receive a signature (`app/api/signals/[id]/vote/route.ts`); + - reactions are unsigned (`app/api/signals/[id]/reactions/route.ts:41-84`); + - notifications are IDOR: `GET ?wallet=` and `POST body.wallet` are trusted + (`app/api/notifications/route.ts:38-46,120-163`); + - market listings are unsigned (`app/api/market/route.ts:40-43`). +- `x-admin-key` and `PHASE_ADMIN_KEY` **fail open when unset** in + `nft-index/subscribe` and moderation. That is unacceptable for a fanout + admin surface. + +A live channel keyed only by `?wallet=` would let anyone subscribe to another +wallet's notifications. Fix the write/read auth before exposing private streams. + +## Q1 — Minimal closed loop + +``` +wallet connect + -> POST /api/live/token { wallet, timestamp, signature } + verify SEP-53 (lib/viewer-signature.ts), mint HttpOnly live_session cookie + -> GET /api/live/stream?channels=signals:global,wallet:G... + text/event-stream, Last-Event-ID, retry: 3000 + -> signal create / upvote / reply / offer + route emits event AFTER the store commit + -> hub -> SSE -> client useLiveChannel + -> merge by version; refetch on gap; toast via use-toast-queue +``` + +The loop is closed when a second browser sees the mutation within one second +without polling, and a reconnect after `kill -9` resumes from `Last-Event-ID` +without duplicating a toast or losing a mutation. + +### Which file can fail it independently + +| Stage | File | Independent failure | +|---|---|---| +| Store commit | `lib/signal-store.ts` | Broken merge; no event emitted because the write throws. | +| Emission | route handlers | Emit before commit → clients see a phantom; emit after response → race with the next read. | +| Auth | `app/api/live/token` (new) | `?wallet=` forgery; replay of a token; cookie not HttpOnly. | +| Fanout | new `LiveFanout` adapter | In-process hub sees only same-instance events. | +| Caps | stream route | Unbounded connections/queue → OOM or silent drops. | +| CSP | `middleware.ts` + `next.config.mjs` | Two policies intersect; `ws://localhost` and `worker-src blob:` are allowed only in `next.config.mjs`. | +| Client merge | `app/explore/page.tsx`, `app/signals/page.tsx` | `loadSeq` discards a live event that arrived before the fetch; offset pagination duplicates/skips. | +| Toast | `hooks/use-toast-queue.ts` | Duplicate events flood the single-visible-toast queue. | + +## Q2 — Replay/idempotency map + +| Write | Key today | Live-update impact | Required change | +|---|---|---|---| +| Signal create | none (signature only) | No idempotency; retry creates duplicates. | `Idempotency-Key` scoped to wallet; unique `(wallet, key)` for 10 min. | +| Signal edit | `If-Match` version (`app/api/signals/[id]/route.ts:162-165`) | Event carries `version`; client ignores stale. | Keep. | +| Reply | optional `parent_version` (`replies/route.ts:160-205`) | TOCTOU before file lock (`lib/signal-store.ts:248-258`). | Check inside the locked mutation. | +| Upvote | optional `If-Match` (`[id]/route.ts:72-85`) | Event should carry new `version` and count. | Verify signature. | +| Poll vote | none | One vote per wallet enforced in store. | Verify signature; emit `signal.voted`. | +| Reaction | 20/60s per wallet, unsigned | Duplicate toggles. | Verify signature; event id = `signal:reaction::::`. | +| Notification read | none, IDOR | Mark-read can race. | Wallet token; `If-Match` on notification version or `updated_at`. | +| Market listing/offer | none for writes | Live event could precede SQLite commit. | Emit after `BEGIN IMMEDIATE` commit; `listing.version`. | +| SSE delivery | `Last-Event-ID` | Not a write, but replay must be exact. | Monotonic `seq` per channel; ring buffer + DB fallback. | + +SSE does not need write idempotency, but it must not turn a transient reconnect +into a duplicate mutation. Mutation requests keep their own idempotency keys; +the stream only replays events. + +### Event envelope + +```ts +export type LiveEvent = { + id: string; // `${channel}:${seq}` — Last-Event-ID + seq: number; // monotonic per channel + type: + | "signal.created" | "signal.updated" | "signal.voted" | "signal.replied" + | "signal.reacted" | "notification.created" | "notification.read" + | "listing.created" | "offer.created" | "offer.accepted" | "resync"; + channel: string; // signals:global | wallet:G... | market:public + version?: number; // entity version for merge + ts: number; // server wall clock, display only + payload: unknown; // already-public projection; never a full store row +}; +``` + +`payload` is a **public projection**. Do not serialize `wallet`, email, private +notification text, or admin fields into a public channel. Private channels use +the same envelope but are authorized per wallet. + +## Q3 — Drift, rotation, failover + +| Failure | Fence | +|---|---| +| Contract-ID drift (indexer events) | `get_config()` (`lib/phase-protocol.ts:623-643`); indexer stores the contract ID that emitted the event and drops mismatches. | +| Issuer rotation | `resolvePhaserLiqClassicAsset()` (`lib/stellar.ts:93`); asset/issuer is part of the event key. | +| CID/gateway drift | `token_uri?c=` + `lib/cid-verification.ts`; `prioritizeGatewaysByHealth` is **server-only** (`lib/phase-nft-metadata-build.ts:59-80`). The browser must not fan out to arbitrary gateways. | +| Fanout provider failover | `LiveFanout` interface with `publish`, `subscribe`, `health`; primary → secondary; if both fail, degrade to polling and emit `live.degraded` (closed label). | +| Mercury webhook | There is no receiver today. Add HMAC verification, replay window, and a contract-ID/event-type allowlist before treating Mercury as a source. Do not expose `MERCURY_JWT` to the client. | +| CSP | Reconcile `middleware.ts` and `next.config.mjs` into one policy. SSE needs `connect-src 'self'` (already allowed). External fanout needs its origin in **both** policies. `worker-src blob:` must be added to middleware if a client worker is used. | +| Deploy skew | Event schema version in the envelope (`v: 1`); client ignores unknown types; server keeps a 5-minute replay window across deploys. | + +## Q4 — Status-code taxonomy + +| Code | When | Headers / recovery | +|---|---|---| +| `401 LIVE_TOKEN_REQUIRED` | No/invalid `live_session` cookie on a private channel. | UI calls `/api/live/token` again (one wallet prompt), then reconnects. | +| `403 LIVE_CHANNEL_FORBIDDEN` | Cookie wallet does not own `wallet:G...`. | Drop the channel; do not retry. | +| `409 LIVE_RESYNC_REQUIRED` | `Last-Event-ID` older than the replay window. | SSE `event: resync` with current versions; client refetches pages. | +| `409` on mutation | Version conflict (existing contract). | `current_version` + `ETag`; merge/rebase; keep draft. | +| `412` on mutation | Precondition failed (stale `If-Match`). | Show current state; do not auto-replay. | +| `428` on edit | Missing `If-Match`. | Client bug; attach the latest ETag. | +| `429 LIVE_RATE_LIMITED` | Too many connections/events per wallet/IP. | `Retry-After`, `X-RateLimit-Limit/Remaining/Reset`; exponential reconnect with jitter. | +| `503 LIVE_UNAVAILABLE` | Fanout/store down. | `Retry-After`; SSE `retry:` field; fall back to 30s polling. | + +SSE-specific semantics: + +- `id:` is the `Last-Event-ID`; `retry: 3000` tells EventSource how long to wait. +- A heartbeat comment (`: ping`) every 15s keeps proxies from closing an idle + stream. +- On `resync`, the client must **replace** paginated state with a fresh fetch, + not merge stale pages; `loadSeq` still guards the newest fetch. +- The UI must not lose a draft: compose/reply drafts are persisted to + `sessionStorage` before any remote merge, and a `409` shows a conflict banner + with "keep mine / reload". + +## Q5 — Migration and rollback + +- **No schema change required for phase 1.** The in-process hub is memory only. +- Phase 2 adds an additive `live_events` table (or provider retention) for + `Last-Event-ID` replay; `CREATE TABLE IF NOT EXISTS`, indexed on + `(channel, seq)`. +- Notification fanout must become atomic before live delivery: + `lib/notification-store.ts` needs the same `withFileLock` + `tmp+rename` + pattern as `lib/signal-store.ts:66-123`. Without it, a live event can announce + a notification that was lost by a concurrent write. +- Flag `phase-551` gates the stream route and client hook. Off: notifications + return to 30s polling, feeds stay fetch-on-mount, no event emission. +- Rollback is unset/restart. Any `live_events` rows remain inert. No provider + secret is rotated by rollback; rotate it separately if compromised. +- Do **not** put a bearer token in the SSE URL. Use the HttpOnly cookie. Logs, + Referer, and browser history must never contain a stream credential. + +## File-by-file invariant map + +| File | Change or ruling-out | Invariant | +|---|---|---| +| `app/api/signals/route.ts` | Emit `signal.created` after commit; add idempotency key. | No phantom events. | +| `app/api/signals/[id]/route.ts` | Emit update/vote; verify upvote signature. | Event version matches stored version. | +| `app/api/signals/[id]/replies/route.ts` | Emit reply; check `parent_version` inside the lock. | No lost/duplicate replies. | +| `app/api/signals/[id]/reactions/route.ts` | Verify signature; emit reaction. | No spoofed reaction events. | +| `app/api/signals/[id]/vote/route.ts` | Verify signature; emit vote. | One vote per wallet. | +| `app/api/notifications/route.ts` | Wallet auth; emit created/read. | No IDOR over live notifications. | +| `app/api/nft-index/subscribe/route.ts` | Fail closed on admin key; add receiver/HMAC before use. | No open webhook registration. | +| `app/api/market/route.ts` | Sign listings; emit created. | No fake seller events. | +| `app/api/explore/route.ts` | Keep `s-maxage`; add `revalidateTag`/live invalidation. | Live insert does not bypass pagination correctness. | +| `app/signals/page.tsx` | `useLiveChannel`; merge by version; keep filters. | No reorder/duplicate under burst. | +| `app/signals/[id]/page.tsx` | Subscribe to the signal channel. | Detail matches list. | +| `app/signals/[id]/signal-detail-client.tsx` | Fix reply signature placeholder; live merge. | Server-verifiable reply. | +| `app/explore/page.tsx` | Keep `loadSeq`; add resync; handle offset drift. | Newest fetch wins. | +| `components/notifications-modal.tsx` | Replace 30s poll with SSE + fallback. | No overlapping polls; no missed badge. | +| `components/signal-compose.tsx` | Draft persistence; idempotency key. | User input survives reconnect. | +| `lib/signal-store.ts` | **Repair the merge first**; emit hook after commit. | Store is the source of truth. | +| `lib/notification-store.ts` | Add lock + atomic rename; emit hook. | No lost notifications. | +| `hooks/use-toast-queue.ts` | Dedupe by event id; cap live burst. | One visible toast, no flood. | +| `middleware.ts` | Reconcile CSP; SSE/fanout origins. | No secret exfiltration; stream allowed. | +| `next.config.mjs` | Reconcile CSP; keep `worker-src blob:` only if needed. | Single effective policy. | + +## New modules (proposed) + +```ts +// lib/live/types.ts +export type LiveChannel = `signals:global` | `market:public` | `wallet:${string}`; + +// lib/live/fanout.ts +export interface LiveFanout { + publish(event: LiveEvent): Promise; + subscribe(channel: string, onEvent: (e: LiveEvent) => void): () => void; + health(): Promise<"ok" | "degraded" | "down">; +} +// InProcessFanout for dev/single instance; ProviderFanout (Ably/Pusher) for prod. + +// app/api/live/stream/route.ts +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +// app/api/live/token/route.ts +// POST { wallet, timestamp, signature } -> Set-Cookie: live_session=; HttpOnly; Secure; SameSite=Lax; Path=/api/live +``` + +## Caps and metrics + +- 3 concurrent streams per wallet, 20 per IP; 256 queued events per connection; + drop oldest non-critical event and increment `live_events_dropped`. +- Heartbeat 15s; reconnect backoff 1s→30s with jitter; `maxDuration` 300s. +- Coalesce same-entity updates within 250ms. +- Closed counters: `live_connections`, `live_events_emitted`, + `live_events_dropped`, `live_reconnects`, `live_auth_failures`, + `live_backpressure`, `live_fanout_degraded`. + +## Tests and acceptance evidence + +- Two clients, one mutation: both receive exactly one event; no duplicate toast. +- Kill the stream mid-event: reconnect with `Last-Event-ID` replays the gap; a + stale ID triggers `resync`. +- Forge a `wallet:` channel with another wallet's cookie → 403. +- Burst 1,000 events: queue cap holds, oldest dropped, metric moves, no OOM. +- External fanout killed: fallback to polling, `live.degraded` metric, UI shows + a non-blocking banner. +- Flag off: notifications poll every 30s exactly as today; no stream route. +- CSP: no console violations under middleware-only and next-config-only + enforcement (the effective intersection). +- `npm run typecheck && npm test` green with the flag off. + +## Risks / non-goals / estimate + +- **Risk:** serverless fanout is the whole problem. Do not ship phase 1 as + "real-time" if the deployment runs more than one instance. +- **Risk:** private channels on unauthenticated reads turn into a wallet IDOR. + Fix auth first. +- **Risk:** event payloads leak private notification text into a public channel. + Use projections, not store rows. +- **Non-goal:** presence/typing/cursors in phase 1; mainnet; replacing SQLite. +- **Estimate:** 1 w transport/auth, 1–2 w store hooks + UI, 1 w fanout/hardening. diff --git a/docs/spikes/552-collaborative-canvas-findings.md b/docs/spikes/552-collaborative-canvas-findings.md new file mode 100644 index 00000000..e419c1bc --- /dev/null +++ b/docs/spikes/552-collaborative-canvas-findings.md @@ -0,0 +1,255 @@ +# Spike 552 — Collaborative Vector Canvas (Multi-Author CRDT, Touch/Pen) + +Status: **proposed spike / research complete, not implemented**. +Batch: 550–599. Files in scope: 18. Effort: 4–8 weeks. + +## Verdict first + +This is **greenfield, not a refactor**. The canvas exists but is unreachable and +local-only; the CRDT files named in the brief do not exist; `yjs` is not a +dependency; there is no `signal_crdt` store, no `/api/signals/[id]/crdt` route, +and no `Y.Text` anywhere. The only collaborative conflict mechanism in the repo +is `expected_version` / `expected_vector_clock` on `POST /api/world`, and its +save is not a CAS. + +A collaborative canvas is feasible, but it should not reuse the signal text +model. Use an **immutable operation log** (grow-only set of ops + deterministic +render order + tombstones for clear), batched over HTTP. Do not send 60 Hz +deltas to SQLite, and do not model vector strokes as `Y.Text`. + +## What the brief claimed vs. what the code actually does + +| Claim | Verified reality | +|---|---| +| "forge-paint-canvas single-user `Segment[]` (path/line/circle, grid-snap 16px)" | True: `components/forge-paint-canvas.tsx:13-16`, `:136`, `:9`. But `Segment` is private, not exported, and has no id/author/timestamp/color/pressure. | +| "mouse-only (onMouseDown/Move/Up, no touch/pen/keyboard, touch-none CSS only)" | True for `ForgePaintCanvas` (`:279-282`, `:277`). `DesignStudioEditor` already uses pointer events + `setPointerCapture` (`:318-323`, `:596-599`), but neither handles pressure/tilt. | +| "signal-crdt lore draft Yjs precedent is Y.Text minimal prefix/suffix diff" | **False.** `lib/signal-crdt.ts`, `lib/signal-crdt-store.ts`, and `app/api/signals/[id]/crdt/route.ts` do not exist and have no git history. `yjs` is absent from `package.json` and `package-lock.json`. There is no `Y.Doc`, `Y.Array`, `Y.Text`, `encodeStateAsUpdate`, or `state_vector` in the app. Text diffing is word-level LCS (`lib/signal-store.ts:diffWords`, `lib/lore-versioning.ts:diffNarrativeText`), not Yjs. | +| "Transport precedent HTTP state-vector" | There is no state-vector transport. `POST /api/world` accepts `expected_version` / `expected_vector_clock` (`app/api/world/route.ts:137-177`), but `saveWorldForCollection` is an unlocked read-modify-write (`lib/narrative-world-store.ts:56-70`). | +| "Seal path + 256-char image validation assumes single author" | The 256 limit is the **image URI string** length, not image bytes (`lib/phase-protocol.ts:1733`). Image checks are 8–8192 px edge (`lib/forge-seal-image.ts:76`) and a 64-byte minimum in the modal (`components/design-studio-modal.tsx:126`). `handleSeal` has no busy state, lock, or idempotency (`:124-129`), so concurrent seals are undefined. | +| "Tool rail labels hardcoded Lápiz/Línea/Círculo" | True for `ForgePaintCanvas` (`:252-254`, footer `:286`). `DesignStudioEditor`/`Modal` already use `pickCopy` (`design-studio-editor.tsx:134-135`, `design-studio-modal.tsx:98-99`). Default app language is EN (`components/lang-context.tsx:15`), so the hardcoded Spanish is visible in the default UI. | +| "Canvas is imported by forge" | False. `ForgePaintCanvas` and `DesignStudioModal` have **no importers**. `app/forge/page.tsx` uses prompt/file/URL only (`:1303-1406`) and calls `composeImageWithPhaseForgeSeal` + `uploadToIPFS` (`:456-460`, `:476-479`). | +| "SQLite docs/updates tables exist" | No. The SQLite schema has `signals`, `signal_replies`, `signal_versions`, `signal_reactions`, not `docs`/`updates` (`lib/sqlite-db.ts:64-131`). | + +## Q1 — Minimal closed loop + +``` +open forge canvas (flag on) + -> POST /api/canvas/[docId]/join { clientId, stateVector } + <- { ops, stateVector, version, snapshot? } + -> pointer/pen strokes -> quantized CanvasOp[] + -> batch every 50 ms or 16 ops -> POST updates { ops, stateVector } + <- 409 + current stateVector if stale -> rebase + -> all peers render sorted ops; last writer wins by (lamport, peer, id) + -> seal: POST /api/canvas/[docId]/seal { expectedVersion } + <- one winner with CID; losers 409 + winner CID + -> IPFS upload (retry path) -> mint/settle +``` + +The loop is proven when two browsers with different network delays converge to +the same pixel output after concurrent strokes, a `clear`, and a seal race, and +a reload reconstructs the document from snapshot + tail ops. + +### Which file can fail it independently + +| Stage | File | Independent failure | +|---|---|---| +| Input | `components/forge-paint-canvas.tsx` | Mouse-only; CSS-stretched backing store; no DPR; non-deterministic floats. | +| Doc state | `lib/canvas/*` (new) | No op identity → duplicates; no tombstone → `clear` resurrects. | +| Persistence | `lib/canvas-store.ts` (new) | No CAS → lost updates; unbounded log → OOM. | +| Transport | `app/api/canvas/[docId]/route.ts` (new) | 60 Hz writes; no batch cap; no state-vector response. | +| Seal | `components/design-studio-modal.tsx` | Two seals both upload; last write wins; lineage forks. | +| IPFS | `lib/ipfs-upload.ts` | Forge uses legacy single-shot, not `uploadToIPFSWithFlag`/retry. | +| i18n | `lib/phase-copy.ts` | Spanish labels in an EN session. | +| Flags | `lib/feature-flags.ts` | No canvas flag; accidental exposure. | + +## Q2 — Replay/idempotency map + +| Write | Key | Store | Double-submit | +|---|---|---|---| +| Append stroke op | `opId = peerId:seq` | `canvas_ops(doc_id, op_id)` primary key | `INSERT ... ON CONFLICT DO NOTHING`; duplicate is a no-op, not an error. | +| Clear | `opId` + `clearThroughLamport` | same table | Idempotent; applies to ops with `lamport <= clearThrough`. | +| Snapshot compaction | `(docId, upToSeq)` | `canvas_snapshots` | One row per seq; rewriting the same seq is a no-op. | +| Seal | `sealId` + `expectedVersion` | `canvas_docs.sealed_cid`, `seal_version` | Conditional `UPDATE ... WHERE version=? AND sealed_cid IS NULL`; exactly one winner. | +| Mint/settle | `invoice_id` | on-chain `PaymentRecord.settled` | Existing contract guard (see spike 550). | +| Upload | CID/content hash | IPFS is content-addressed | Re-upload of identical bytes yields the same CID; safe to retry. | + +The op log is the idempotency record. Do not add a second sequence counter that +can disagree with `opId`. + +## Q3 — Drift, rotation, failover + +| Failure | Fence | +|---|---| +| Doc schema drift | `schemaVersion` in the doc row and every snapshot; reject unknown versions with `CANVAS_SCHEMA_UNSUPPORTED` rather than guessing. | +| Peer-id collision | `peerId` is a random 128-bit id minted per tab; never a wallet address (a wallet can have two tabs). Op id includes it. | +| Contract-ID drift (seal → mint) | `get_config()` + the contract ID captured when the doc was created; a seal against a stale contract fails closed. | +| CID/gateway drift | `lib/cid-verification.ts` verifies the sealed bytes; `token_uri?c=` keys the metadata cache. | +| Issuer rotation | `resolvePhaserLiqClassicAsset()` for any classic asset in the seal path. | +| Store failover | SQLite is local; on Vercel it is not shared across instances. A real multi-instance canvas needs a shared store (Turso/Postgres) before phase 2. Document this as a hard limit, not a bug. | +| Upload failover | Use `uploadToIPFSWithFlag` / `uploadToIpfsWithRetry` (`lib/ipfs-upload-retry.ts`), not the legacy `uploadToIPFS` currently called by forge. | + +## Q4 — Status-code taxonomy + +| Code | When | Recovery | +|---|---|---| +| `400 CANVAS_OP_INVALID` | Unknown op kind, non-finite/out-of-range coordinate, op too large. | Drop the op, keep the local draft, show a non-blocking error. | +| `401 CANVAS_UNAUTHORIZED` | No wallet proof for a write/seal. | Re-sign; never send the wallet address as a signature. | +| `403 CANVAS_FORBIDDEN` | Wallet is not an editor of the doc. | Read-only mode. | +| `409 CANVAS_VERSION_CONFLICT` | `stateVector`/`expectedVersion` is stale. | Response includes `stateVector` + `ops`; rebase local ops, replay them, do not discard input. | +| `409 CANVAS_ALREADY_SEALED` | Seal race lost. | Show the winner CID; offer "view sealed artifact". | +| `412 CANVAS_PRECONDITION_FAILED` | Doc sealed/archived; op references an unknown parent snapshot. | Reload from snapshot; disable drawing. | +| `413 CANVAS_DOC_TOO_LARGE` | Op count or byte cap exceeded. | Force compaction; reject new ops with a clear message. | +| `428 CANVAS_VERSION_REQUIRED` | Seal/compaction without `If-Match`/`expectedVersion`. | Client bug; attach latest version. | +| `429 CANVAS_RATE_LIMITED` | Batch rate per peer/IP. | `Retry-After`; increase batch window. | +| `503 CANVAS_STORE_UNAVAILABLE` | SQLite/shared store down. | Keep local ops in memory/IndexedDB; retry with the same op ids. | + +## Q5 — Migration and rollback + +- New additive tables via `CREATE TABLE IF NOT EXISTS`: + +```sql +CREATE TABLE IF NOT EXISTS canvas_docs ( + id TEXT PRIMARY KEY, + signal_id TEXT, + schema_version INTEGER NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + state_vector TEXT NOT NULL DEFAULT '{}', + sealed_cid TEXT, + seal_version INTEGER, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS canvas_ops ( + doc_id TEXT NOT NULL, + op_id TEXT NOT NULL, + peer_id TEXT NOT NULL, + lamport INTEGER NOT NULL, + kind TEXT NOT NULL, + op_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (doc_id, op_id) +); +CREATE INDEX IF NOT EXISTS idx_canvas_ops_order + ON canvas_ops(doc_id, lamport, peer_id, op_id); + +CREATE TABLE IF NOT EXISTS canvas_snapshots ( + doc_id TEXT NOT NULL, + up_to_seq INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (doc_id, up_to_seq) +); +``` + +- Flag `phase-552` gates the canvas mount, join/update/seal routes, and the + `forgeCanvas` copy keys. Off: the canvas component is not rendered and the + routes return 404; all tables remain inert. +- Rollback: unset/restart. No destructive migration; sealed CIDs remain on IPFS + and the corresponding on-chain mint is unaffected (the mint is the artifact, + not the doc). +- If Yjs is chosen later, pin the exact version and add it as an explicit + dependency; do not rely on a transitive copy. The current repo has no Yjs, so + this spike does not inherit a version. + +## Data model (recommended: immutable ops, not `Y.Text`) + +```ts +type Fixed = number; // 1/16 px integer; deterministic across DPR/resizes + +type CanvasOp = + | { id: string; peer: string; lamport: number; kind: "path"; + points: [Fixed, Fixed][]; color: string; width: number } + | { id: string; peer: string; lamport: number; kind: "line"; + x0: Fixed; y0: Fixed; x1: Fixed; y1: Fixed; color: string; width: number } + | { id: string; peer: string; lamport: number; kind: "circle"; + cx: Fixed; cy: Fixed; r: Fixed; color: string; width: number } + | { id: string; peer: string; lamport: number; kind: "clear"; + throughLamport: number }; +``` + +Rules: + +1. **Coordinates are integers in logical canvas space**, not CSS pixels and not + DPR-scaled. `clientToCanvas` must use the element's `getBoundingClientRect()` + and the logical `width`/`height`, then `Math.round(v * 16)`. +2. **Render order** is `(lamport, peer, id)` ascending. Concurrent strokes are + deterministic; there is no array-index race. +3. **Clear is a tombstone**, not a delete. It hides ops with + `lamport <= throughLamport`. Deleting rows would let a late op resurrect the + cleared drawing. +4. **Compaction** rewrites a snapshot at `upToSeq` and may delete ops at or + below it only after every active peer has acknowledged that seq (or after a + TTL). Until then, keep the op log. +5. **Caps:** 10,000 ops or 2 MB per doc; 16 ops / 50 ms per POST; 64 KB per + request. On cap, force a snapshot and reject new ops with + `CANVAS_DOC_TOO_LARGE`. + +If Yjs is preferred, use `Y.Map` (immutable values) plus the +same deterministic render sort. Do **not** use `Y.Array` insertion order as the +z-order; concurrent inserts interleave by client id and are not a total order you +control. Do not use `Y.Text` for vector ops. + +## Input and i18n + +- `onPointerDown/Move/Up/Cancel`, `setPointerCapture`, `touch-action: none`, + `getCoalescedEvents()` for pen smoothness, `pointerType`/`pressure` stored on + the op (optional fields; absent means no pressure device). +- Palm rejection: ignore `pointerType === "touch"` while a pen is active; never + ignore touch entirely (phones are valid clients). +- Keyboard alternative: arrow-key cursor + Enter to place a point, Escape to + cancel; focus ring on the canvas. +- Backing store scales by `devicePixelRatio` for crispness, but all stored + coordinates stay in logical space. A resize must not rewrite ops. +- Replace hardcoded `Lápiz`/`Línea`/`Círculo` and the footer with + `pickCopy(lang).forgeCanvas.*` keys in EN and ES (`lib/phase-copy.ts`). +- `prefers-reduced-motion` disables grid animation, not drawing. + +## File-by-file invariant map + +| File | Change or ruling-out | Invariant | +|---|---|---| +| `components/forge-paint-canvas.tsx` | Replace local `Segment[]` with props/doc ops; pointer events; quantized coords; i18n. | Deterministic geometry. | +| `components/design-studio-editor.tsx` | Decide: keep as bitmap editor or migrate to vector; do not mix models. | One document model. | +| `components/design-studio-modal.tsx` | Seal busy state, `expectedVersion`, single winner. | No duplicate seal. | +| `lib/signal-crdt.ts` | **Does not exist**; create as `lib/canvas/crdt.ts` or rule out Yjs. | Op merge semantics. | +| `lib/signal-crdt-store.ts` | **Does not exist**; create `lib/canvas-store.ts` with CAS. | Durable op log. | +| `app/api/signals/[id]/crdt/route.ts` | **Does not exist**; create `app/api/canvas/[docId]/route.ts`. | Batch/state-vector contract. | +| `app/forge/page.tsx` | Mount the canvas behind `phase-552`; wire seal to retry upload. | Feature is reachable. | +| `lib/forge-seal-image.ts` | Keep visual seal; do not treat it as cryptographic. | Visual stamp only. | +| `lib/forge-nanobanana.ts` | Rule out: prompt generation is not canvas state. | No hidden coupling. | +| `lib/ipfs-upload.ts` | Use retry/flag path for seals; CID is the content identity. | Upload idempotency. | +| `lib/ipfs-upload-retry.ts` | Reuse for seal; checksum + backoff. | No partial pin. | +| `lib/phase-protocol.ts` | Seal/mint remains wallet-signed; no server key. | Signing boundary. | +| `lib/lore-versioning.ts` | Rule out: lore versions are text, not canvas ops. | No false reuse. | +| `app/api/forge-agent/route.ts` | Rule out: agent pipeline consumes prompts, not strokes. | No blob upload to agent. | +| `app/api/forge-agent/status/route.ts` | Rule out; note it reads a different store than `/api/jobs`. | No status confusion. | +| `hooks/use-toast-queue.ts` | Add sync/conflict/seal states; dedupe by op/seal id. | No toast flood. | +| `lib/phase-copy.ts` | Add `forgeCanvas` EN/ES keys; remove hardcoded Spanish. | Locale consistency. | +| `lib/feature-flags.ts` | Add `phase-552` when the route lands. | Default off, rollback. | + +## Tests and acceptance evidence + +- Convergence property test: apply the same op set in 100 random orders → same + rendered hash. +- Concurrent stroke + clear: late stroke does not resurrect cleared content. +- Quantization: same client point at DPR 1/2/3 and two CSS sizes → same `Fixed`. +- Op cap/compaction: 10k ops → snapshot + tail replay equals pre-compaction. +- Seal race: 20 concurrent seals → exactly one CID, 19 × 409 with the winner. +- Touch/pen: synthetic pointer events, pen pressure, palm rejection, keyboard. +- i18n: EN/ES screenshot or key assertion; no hardcoded Spanish. +- Flag off: canvas not mounted; routes 404; tables untouched. +- `npm run typecheck && npm test` green. + +## Risks / non-goals / estimate + +- **Risk:** multi-instance SQLite is not shared. Do not claim real-time + collaboration on Vercel without a shared store. +- **Risk:** unbounded op logs. Compaction and caps are part of the feature, not + hardening. +- **Risk:** seal lineage forks. The seal CAS is mandatory. +- **Non-goal:** presence cursors, undo-per-peer, image layers, 60 Hz transport, + mainnet. +- **Estimate:** 1–2 w model/transport, 2–3 w canvas/input/store, 1–2 w + seal/i18n/hardening. diff --git a/docs/spikes/553-webgl-artifact-viewer-findings.md b/docs/spikes/553-webgl-artifact-viewer-findings.md new file mode 100644 index 00000000..0851239f --- /dev/null +++ b/docs/spikes/553-webgl-artifact-viewer-findings.md @@ -0,0 +1,243 @@ +# Spike 553 — WebGL Artifact Viewer with Protected Textures + +Status: **proposed spike / research complete, not implemented**. +Batch: 550–599. Files in scope: 18. Effort: 3–8 weeks. + +## Verdict first + +The current "protection" is a client-side illusion. The full-resolution IPFS URL +is shipped in the RSC payload, ``, `/api/explore`, and +`/api/metadata`; `/api/ipfs/[...cid]` returns `Access-Control-Allow-Origin: *` +and the viewer never sets `x-phase-gated`, so verified CIDs receive a one-year +public cache. The 2D canvas watermark runs **in the browser** and fails open to +the unwatermarked URL when every gateway fails. + +A WebGL shader watermark does not fix that. A texture uploaded to the GPU is +still readable from the page; `readPixels`/screenshot/DevTools can exfiltrate it. +The only real boundary is **not sending the full-resolution bytes to an +unauthorized client**. The spike must therefore deliver, in order: + +1. a server-side texture route that returns a **watermarked derivative** for + non-owners and the original only to an authorized owner; +2. a short-lived, token-bound grant (the existing `viewer_jti` verifier is the + right primitive); +3. then the Three.js presentation layer, with a graceful CSS fallback. + +Doing 3 before 1 and 2 ships a prettier leak. + +## What the brief claimed vs. what the code actually does + +| Claim | Verified reality | +|---|---| +| "CSS tactical-holo-wrap + blur/pixel gate + 1-line chamber/artifact-viewer stub" | The stub exists (`components/chamber/artifact-viewer.tsx` contains only `placeholder`) but is **unused**. The real viewer is `components/phase-artifact-visualizer.tsx` (923 lines), rendering a DOM ``. `FusionChamber` renders it at `fusion-chamber.tsx:1951`. | +| "Protection model (protectArt, ownerUnlocked, 2D canvas baked watermark toDataURL)" | True: `protectArt`/`ownerUnlocked` at `phase-artifact-visualizer.tsx:260-261`; watermark at `:33-86` (`toDataURL` at `:82`); fallback to the raw URL at `:320`. The owner gate is computed **in the browser**. | +| "breaks on WebGL textures: texImage2D + readPixels allows exfil" | True in principle; the current viewer has no WebGL at all. WebGL would not make it worse, but a shader watermark is not a security control. | +| "Gateway fanout gwIdx retry in 3 components" | Close. `PhaseArtifactVisualizer` owns `gwIdx` (`:257-258`), `IpfsDisplayImg` owns `idx` (`:16-23`), `WalletAvatar` has a separate list (`:10-16`). `PhaseArtifactVisualizer` also has a second independent watermark retry loop (`:300-319`). None has a timeout/abort. Client retries use a **static** order; `prioritizeGatewaysByHealth` is server-only (`lib/phase-nft-metadata-build.ts:59-80`). | +| "No three/@react-three in deps (~600KB gz)" | `three`, `@react-three/fiber`, `@react-three/drei` are all absent from `package.json`/`package-lock.json`. The 600 KB figure is **not measured in this repo**; do not repeat it as fact. | +| "Canvas replaces img alt + aria lightbox" | The lightbox has `role="dialog"`, `aria-modal`, Escape, and a close button, but no focus trap/focus restore, and both images use `alt=""` (`:455`, `:843`). The dialog name is a generic "Expand". | +| "Watermarked vs owner-decrypt semantics as materials/uniforms without leaking full-res URL in NEXT_DATA" | Today the full-res URL **is** in the RSC payload and OG metadata: `app/collection/[id]/page.tsx:51-64,80-96`; `/api/explore` returns it (`:124-131`); `/api/metadata/[id]` returns it; `/api/ipfs` is CORS `*` (`:8-12`). The current gate is cosmetic. | +| "CSP worker-src blob: present but img-src data: blob: https: allows bypass" | There are **two** CSPs. `middleware.ts:18-19` has `img-src 'self' data: blob: https:` and `connect-src 'self' https://*.stellar.org https://soroban-testnet.stellar.org wss:` but **no `worker-src`**. `next.config.mjs:55-59` has `worker-src 'self' blob:`, `connect-src 'self' https: wss:`, and `img-src ... http:`. The browser enforces the intersection, so middleware blocks blob workers and direct gateway fetches. | + +### Baseline blockers + +- `viewerSignatureShort` is imported by `phase-artifact-visualizer.tsx:6` and + `fusion-chamber.tsx:89`, but `lib/viewer-signature.ts` does not export it. +- `components/chamber/artifact-viewer.tsx` and `settlement-wizard.tsx` are + literal `placeholder` files; `fusion-chamber.tsx:71-75` imports five modules + that do not exist. The current tree is not build-clean. +- `lib/viewer-authorization.ts` has a real verifier (Ed25519, contract/network/ + token/exp/jti binding, single-use SQLite `viewer_jti`), but nothing calls it + for image delivery. `/api/phase-nft/verify` returns only a boolean + (`:97-167`). + +## Q1 — Minimal closed loop + +``` +owner/non-owner opens /collection/[id] + -> server renders metadata WITHOUT the full-res image URL + -> client POST /api/phase-nft/verify { tokenId, wallet, viewerClaims, viewerSignature } + <- { verified, owner, texture: { url, token, expiresAt, variant: "preview"|"original" } } + -> GET /api/artifact/[tokenId]/texture?token=...&v=preview + <- server fetches CID, verifies multihash, applies a server-side watermark, + returns WebP/PNG with Cache-Control: private, no-store, Vary: Authorization + -> dynamic import three -> TextureLoader -> shader material -> lightbox + -> WebGL unavailable / reduced motion -> CSS holo fallback with alt text +``` + +The loop is proven when an unauthenticated browser cannot obtain the original +bytes from the HTML, RSC payload, OG tags, explore JSON, or a direct texture URL; +and an authorized owner can, with a grant that expires. + +### Which file can fail it independently + +| Stage | File | Independent failure | +|---|---|---| +| URL exposure | `app/collection/[id]/page.tsx`, `app/api/explore/route.ts`, `lib/phase-nft-metadata-build.ts` | Full-res URL published before the viewer runs. | +| Grant | `lib/viewer-authorization.ts`, `app/api/phase-nft/verify/route.ts` | Verifier exists but no token is issued; or a boolean is mistaken for a grant. | +| Cache | `app/api/ipfs/[...cid]/route.ts` | Public one-year cache + CORS `*`; `x-phase-gated` is never set. | +| Texture | new `/api/artifact/[tokenId]/texture` | Fetches a third-party gateway directly, leaking the CID; no watermark. | +| CSP | `middleware.ts` + `next.config.mjs` | Two policies; direct gateway fetch blocked or `blob:` worker blocked. | +| Renderer | `components/phase-artifact-visualizer.tsx` | No fallback, no dispose, no context-loss handling. | +| A11y | same | Canvas with no accessible name/alt, no keyboard orbit. | +| Build | `fusion-chamber.tsx`, placeholder imports, missing `viewerSignatureShort` | App does not compile. | + +## Q2 — Replay/idempotency map + +The viewer is read-mostly; the only write is the grant issuance. + +| Write | Key | Store | Replay behavior | +|---|---|---|---| +| Viewer grant | `jti` | SQLite `viewer_jti` PK + `INSERT ... ON CONFLICT DO NOTHING` (`lib/viewer-authorization.ts:70-116`) | Second presentation rejected. | +| Texture token | HMAC over `(tokenId, contractId, wallet, variant, exp, nonce)` | Stateless, 60s TTL | Repeated GETs within TTL are allowed; token is not single-use (textures are fetched more than once). | +| Texture cache | `sha256(bytes)` + `variant` | Server cache keyed by CID+variant | Content-addressed; safe to reuse. | +| NFT ownership | on-chain `ownerOf`/`TokenOwner` | Chain | Re-check on grant; do not trust a cached owner past TTL. | +| Refund state | see spike 550 | Chain + refund ledger | A refunded token must not receive an "original" grant. | + +Do not put the texture token in a URL that is logged or cached publicly. Prefer +an HttpOnly cookie set by `/api/phase-nft/verify`, or a `Authorization: Bearer` +header fetched by the client (not an ``). If a URL token is unavoidable, +keep it short-lived, bind it to the token id, and mark the response +`private, no-store`. + +## Q3 — Drift, rotation, failover + +| Failure | Fence | +|---|---| +| Contract-ID drift | `get_config()` (`lib/phase-protocol.ts:623-643`); grant payload includes `contractId`, and the texture route re-checks it. | +| Issuer rotation | `resolvePhaserLiqClassicAsset()` (`lib/stellar.ts:93`) for classic assets; never infer from a stale env. | +| CID poisoning | `lib/cid-verification.ts` verifies the multihash before caching/serving; reject unverifiable CIDs. | +| Gateway failover | Server-side `fetchWithIpfsFallback` + `prioritizeGatewaysByHealth`; the browser requests only `/api/artifact/...`, never Pinata/dweb/ipfs.io directly. | +| Ownership change | Re-read owner on grant; a transfer invalidates the old owner's "original" variant at the next grant. | +| CSP drift | One source of truth for CSP; if both files must emit, generate both from the same builder and add an intersection test. | +| WebGL context loss | Listen for `webglcontextlost`, prevent default, and fall back to the `` renderer. | +| Bundle failure | Dynamic import with `ssr:false`; if the chunk fails, render the CSS fallback, not a blank box. | + +## Q4 — Status-code taxonomy + +| Code | When | Headers / recovery | +|---|---|---| +| `401 TEXTURE_TOKEN_REQUIRED` | No/invalid grant for a gated token. | `WWW-Authenticate`; UI requests a grant; fallback preview. | +| `403 TEXTURE_FORBIDDEN` | Grant wallet is not the owner and requested `variant=original`. | Serve `preview` instead, or 403; never silently upgrade. | +| `404 TEXTURE_NOT_FOUND` | Unknown token/CID or no verified metadata. | Placeholder + link to the gateway. | +| `409 TEXTURE_REFUNDED` | Token is refunded/held (spike 550). | Show `REFUNDED`; no texture. | +| `412 TEXTURE_GRANT_EXPIRED` | `exp` passed or ownership changed. | Re-issue the grant; do not cache a stale original. | +| `429 TEXTURE_RATE_LIMITED` | Per-wallet/IP texture fetch cap. | `Retry-After`; keep the last frame. | +| `503 TEXTURE_GATEWAY_UNAVAILABLE` | All gateways failed / circuit open. | `Retry-After`; CSS fallback. | + +The UI never loses the user's place: the lightbox stays open, shows the +watermarked preview, and retries the original in the background. + +## Q5 — Migration and rollback + +- **No schema change.** The grant uses the existing `viewer_jti` table. +- Add `three` as an exact dependency (and only `three`; raw Three.js, not the + R3F stack, unless measured otherwise). Import it with + `next/dynamic(..., { ssr: false })` so the chamber bundle does not grow for + every page. +- The texture route is additive; flag `phase-553` gates it and the WebGL + renderer. Off: the current DOM/CSS viewer remains, the texture route returns + 404, and no full-res URL is newly exposed. +- Rollback: unset/restart. The CSS viewer is still present; no data migration to + undo. If the server-side watermark derivative was cached, it is inert. +- **Do not** make the rollback depend on removing `three` from + `package.json`; a dynamic chunk that is not imported is harmless. +- Reconcile CSP in the same change; a WebGL rollout that works in dev and is + blocked by middleware in prod is a failed rollout. + +## Rendering design + +```ts +// dynamic, client-only +const ArtifactCanvas = dynamic(() => import("./artifact-canvas"), { + ssr: false, + loading: () => , +}); + +// artifact-canvas.tsx +const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + powerPreference: "high-performance", + preserveDrawingBuffer: false, // raises the cost of a trivial canvas readback +}); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + +const texture = await new THREE.TextureLoader().load(textureUrl, undefined, undefined, onError); +texture.colorSpace = THREE.SRGBColorSpace; +texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy()); + +// Non-owner: the server has already watermarked the derivative. The shader +// watermark is defense-in-depth, not the security boundary. +``` + +- Dispose geometry/material/texture/renderer on unmount; cancel the RAF loop. +- `prefers-reduced-motion`: no auto-orbit, no parallax; static camera. +- `IntersectionObserver` pauses rendering when off-screen. +- Low-end fallback: if WebGL2 is unavailable, or the context is lost twice, + render the CSS holo ``. +- No `NEXT_DATA` full-res URL: the RSC payload carries a token id and a + variant, not the image URL. + +## Accessibility + +- The canvas gets `role="img"` and an `aria-label` containing the artifact name, + not just "artifact". +- A visually hidden `{name}` fallback is always present for screen + readers; the canvas is `aria-hidden` when the fallback is active. +- Keyboard: focusable canvas, arrow keys orbit, `+`/`-` zoom, Enter opens the + lightbox, Escape closes. +- Lightbox: focus trap, initial focus on close, focus restored to the opener, + `aria-labelledby` pointing at the artifact name. +- `prefers-reduced-motion` disables the decrypt pulse and auto-rotation. +- Color is not the only state signal: `REFUNDED`/`LOCKED`/`OWNED` have text. + +## File-by-file invariant map + +| File | Change or ruling-out | Invariant | +|---|---|---| +| `components/phase-artifact-visualizer.tsx` | Replace `` with dynamic canvas + fallback; remove client-only gate as the authority. | No full-res URL in DOM for non-owners. | +| `components/chamber/artifact-viewer.tsx` | Implement or delete the placeholder; do not leave a fake stub. | One viewer, not two. | +| `components/crt-collection-preview.tsx` | Use the texture route; add variant/alt. | Collection list does not leak originals. | +| `components/ipfs-display-img.tsx` | Keep for non-gated images; add timeout/abort. | Gateway failover is bounded. | +| `components/phase-protected-preview.tsx` | Stop claiming "verified" from a string compare; use the grant. | UI signal is not auth. | +| `components/fusion-chamber.tsx` | Fix missing imports; dynamic viewer; no hardcoded `mode="verified"`. | Real verification mode. | +| `components/chamber/settlement-wizard.tsx` | Implement or remove placeholder; settlement must not render a broken viewer. | Build-clean. | +| `app/chamber/page.tsx` | Keep Suspense fallback; add error boundary. | No blank chamber. | +| `app/collection/[id]/page.tsx` | Remove full-res `nft.image` from RSC/OG for gated tokens; emit token id + preview. | No URL leak. | +| `app/explore/page.tsx` | Consume `preview` variant only. | Public list cannot fetch originals. | +| `app/dashboard/page.tsx` | Same; owner-only originals. | Dashboard does not leak. | +| `lib/phase-protocol.ts` | Texture/grant helpers; no server key. | Signing boundary. | +| `lib/viewer-authorization.ts` | Issue texture token from the verified claims; keep `jti` single-use. | Grant is bound and expires. | +| `lib/viewer-signature.ts` | Restore `viewerSignatureShort`; keep SEP-53. | Build + client signing. | +| `lib/cid-verification.ts` | Verify before watermark/cache. | No poisoned texture. | +| `next.config.mjs` | Single CSP source; no wildcard texture origins. | Effective policy. | +| `middleware.ts` | Same CSP builder; allow only the texture route. | No bypass. | +| `app/layout.tsx` | No preload of gated textures; keep JSON-LD inline. | No early fetch. | + +## Tests and acceptance evidence + +- RSC/HTML test: a non-owner response contains no `ipfs://` original and no + `/api/ipfs/` full-res URL in `og:image`. +- Grant: valid signature → token; expired/replayed `jti` → 401/412; wrong + contract/token → 403. +- Texture: non-owner gets a watermarked derivative; owner gets original; a + refunded token gets 409. +- CSP: no violations under the intersection; direct gateway fetch is blocked. +- WebGL: context loss → fallback; reduced motion → static; keyboard orbit works; + screen reader sees the artifact name. +- Bundle: measure the dynamic chunk; record the number in the PR (do not claim + 600 KB without measurement). +- Flag off: current CSS viewer, no texture route, no regression. +- `npm run typecheck && npm test` green. + +## Risks / non-goals / estimate + +- **Risk:** treating a shader watermark as DRM. It is not; the server-side + derivative is the boundary. +- **Risk:** two CSPs. Fix before adding WebGL. +- **Risk:** public cache headers on a private texture. `private, no-store` and + `Vary: Authorization` are mandatory. +- **Non-goal:** screenshot prevention, mainnet, production DRM, 3D model + formats, or replacing IPFS. +- **Estimate:** 1 w grant/texture route, 1–2 w renderer/fallback, 1 w + a11y/CSP/hardening. Add 1 w if the server-side watermark pipeline is new. diff --git a/docs/spikes/SPIKE_INDEX_550_599.md b/docs/spikes/SPIKE_INDEX_550_599.md new file mode 100644 index 00000000..0a0b029d --- /dev/null +++ b/docs/spikes/SPIKE_INDEX_550_599.md @@ -0,0 +1,87 @@ +# Spike Index — Batch 550–599 + +Status: **proposed spike / research complete, implementation not started**. +Baseline: `main` @ `2e2f64e` (branch `spike/batch-550-599`). All line references +below are for that baseline. + +This batch contains four independent, cross-boundary spikes. Each one was +specified as a *proposed* advanced integration spike, so the deliverable of this +batch is a code-grounded design and a go/no-go decision — not a half-merged +feature. No runtime route, contract entrypoint, or dependency was added. + +The collaborative-canvas brief appeared twice in the source text; it is tracked +once as spike 552. The refund, live-update, canvas, and WebGL briefs are the +four unique spikes in this batch. + +> Note on the brief: it references `docs/spikes/SPIKE_INDEX_500_549.md` for the +> previous batch. That file is **not present in this tree**. The only prior +> spike documents here are `033`, `035`, `036`, and `037`. The "does not +> duplicate 500–549" claim therefore cannot be verified from the repository; it +> is treated as an external dependency for review. + +## Batch map + +| # | Spike | Proposed flag | Files | Effort | Verdict | +|---|---|---|---|---|---| +| 550 | Refundable settlement for irreversible forge payments | `phase-550` | 16 | 4–8 w | **NO-GO until two preconditions land**: `initialize` re-entry guard and a custody model for the 95/5 split | +| 551 | Real-time live updates (SSE / fanout) for signals, notifications, market, explore | `phase-551` | 20 | 3–6 w | **Conditional GO** after the signal store is repaired and write auth is tightened; in-process SSE first, external fanout second | +| 552 | Collaborative vector canvas (multi-author CRDT, touch/pen) | `phase-552` | 18 | 4–8 w | **Conditional GO**; the canvas is currently unreachable, so this is greenfield, not a refactor | +| 553 | WebGL artifact viewer with protected textures | `phase-553` | 18 | 3–8 w | **Conditional GO** after the texture authorization model is fixed; a shader watermark alone is not a security boundary | + +## Shared invariants (apply to every spike) + +1. **Signing stays client-side.** Freighter / Stellar Wallet Kit signs; the + server only holds custodied `ADMIN`, `FAUCET_DISTRIBUTOR`, `AGENT`, and issuer + keys (`PROJECT_ARCHITECTURE.md` §3). +2. **Flags default off.** A flag is only added to `lib/feature-flags.ts` when a + route or behavior exists to gate. The proposed names above are reserved in + this document; they are deliberately **not** in `FLAG_ENV_MAP` while the + features are unimplemented, so no dead flag can be mistaken for a shipped + control. +3. **Three clocks.** SQLite `version` CAS, JSON file locks / atomic + `tmp+rename`, and Yjs/vector-clock ordering are different mechanisms with + different failure modes. A spike must name which clock owns each write. +4. **No PII in metric labels.** Counters use closed labels only; wallet, signal + text, CID, and prompt are never label values. +5. **Testnet-only assumptions are fenced.** Friendbot, open `mint_public`, the + placeholder payer `GAAA...`, and testnet passphrases must be explicit and + must fail closed on mainnet config. +6. **Docs and env move together.** Contract ID, issuer, or asset changes update + `.env.local.example`, `PROJECT_ARCHITECTURE.md`, `docs/TECHNICAL.md`, and the + relevant spike document in the same change. + +## Baseline hazards found while spiking + +These are pre-existing and block *any* safe implementation. They are listed here +because each spike touches at least one of them: + +| Hazard | Evidence | Impact | +|---|---|---| +| `initialize` can be called again | `contracts/phase-protocol/src/lib.rs:430-460` has no "already initialized" check | Anyone can re-point the authorized token/treasury and set themselves as `Admin`, then call `upgrade` (`:464-472`). This invalidates every admin-gated refund or texture grant. | +| Signal store is a broken JSON/SQLite merge | `lib/signal-store.ts:154-177` references undeclared `conditions`, unimported `getDb`, and redeclares `items`; `voteOnPoll`/scheduled exports are missing while routes import them | `GET /api/signals` and poll/scheduled routes cannot run on this HEAD. A repair exists on `origin/fix/signal-store-sqlite-merge-repair` (`936e42a`) but is not merged. | +| Chamber components are placeholders | `components/chamber/artifact-viewer.tsx` and `settlement-wizard.tsx` contain the literal text `placeholder`; `fusion-chamber.tsx:71-75` imports five modules that do not exist | The app is not build-clean before 553 starts. | +| Missing viewer export | `viewerSignatureShort` is imported by `phase-artifact-visualizer.tsx:6` and `fusion-chamber.tsx:89` but not exported by `lib/viewer-signature.ts` | Build/runtime failure in 553's primary surface. | +| Replay test is broken | `tests/replay-settlement.test.ts:2` imports `mkdtmpSync` from `fs`; it also asserts `used === true` before marking (`:19-22`) | 550 has no valid replay regression test today. | +| `used-settlements.json` bypasses the platform-safe data path | `lib/settlement-store.ts:4` uses `process.cwd()/.data` instead of `serverDataJsonPath` | On Vercel it can be read-only or ephemeral; replay protection silently degrades. | + +## Recommended order + +1. **Repair baseline** (signal store, chamber placeholders, viewer export, + replay test, `initialize` guard, `settlement-store` path). This is not a + spike; it is the floor. +2. **550 contract custody** — everything financial depends on it. +3. **551 live updates** — read-only fanout is the lowest-risk visible feature, + but only after write auth is fixed. +4. **553 WebGL viewer** — consumes the same viewer grant that 550/551 must not + weaken. +5. **552 collaborative canvas** — greenfield and independent; can run in + parallel once the baseline is green. + +## What "done" means for this batch + +Each numbered document answers the five spike questions with concrete types, +SQL, contract storage, status codes, and a file-by-file invariant table. Closing +any one of them still requires the acceptance evidence listed in its final +section (testnet tx hashes, concurrency proof, abuse proof, resilience proof, +EN/ES copy, flag row, and a green `npm run typecheck && npm test` with all flags +off). Those proofs cannot be produced by a documentation-only change.