Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| | |
|---|---|
| **Last updated** | 2026-10-08 (real testnet `create_lock` verified; M3-06 narrowed to the app's own signing path) |
| **Last updated** | 2026-10-08 (M3-14: automated fragment-leak test) |
| **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) |
| **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) |
| **Readiness** | See [§3](#3-progress-snapshot) |
Expand Down Expand Up @@ -131,15 +131,15 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| 1 M0 Validate | 17 | 0 | 1 | 16 | 0 | 0 | 0 | 0% |
| 2 Contract + registry | 36 | 16 | 5 | 15 | 0 | 0 | 0 | 44% |
| 3 SDK + indexer | 19 | 1 | 1 | 17 | 0 | 0 | 0 | 5% |
| 4 App | 26 | 13 | 6 | 7 | 0 | 0 | 0 | 50% |
| 4 App | 26 | 14 | 5 | 7 | 0 | 0 | 0 | 54% |
| 5 Testnet pilot | 7 | 0 | 0 | 7 | 0 | 0 | 0 | 0% |
| 6 Pre-mainnet features | 10 | 0 | 0 | 10 | 0 | 0 | 0 | 0% |
| 7 Hardening | 26 | 0 | 0 | 26 | 0 | 0 | 0 | 0% |
| 8 Launch | 8 | 0 | 0 | 8 | 0 | 0 | 0 | 0% |
| 9 Conditional ramp | 9 | 0 | 0 | 0 | 0 | 9 | 0 | n/a |
| 10 Wave + community | 8 | 1 | 0 | 7 | 0 | 0 | 0 | 13% |
| 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a |
| **All** | **201** | **43** | **15** | **118** | **0** | **25** | **0** | **24%** |
| **All** | **201** | **44** | **14** | **118** | **0** | **25** | **0** | **25%** |

---

Expand Down Expand Up @@ -303,7 +303,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| M3-11 | `/payee` dashboard (lists via indexer) | app | P0 | DONE | M2-12 | Filter by status and reference |
| M3-12 | `/attester` tooling: read-only account checks (trustline, authorization, XLM float) | app | P1 | TODO | M1-26 | Checks match the checklist script |
| M3-13 | `/r/[txHash]/[eventIndex]` receipt page and `/verify` | app | P0 | DONE | M2-06 | Shows "Payment to verified payee"; Valid or Not valid |
| M3-14 | Security headers, strict CSP, `Referrer-Policy`, no third-party scripts on `/claim/*` | app | P0 | IN PROGRESS | M3-09 | Automated test confirms fragment never appears in any network request |
| M3-14 | Security headers, strict CSP, `Referrer-Policy`, no third-party scripts on `/claim/*` | app | P0 | DONE | M3-09 | Automated test confirms fragment never appears in any network request |
| M3-15 | `AssetLabel` (code and issuer), `AmountDisplay`, UTC+local time components | app | P0 | IN PROGRESS | M3-01 | Used everywhere money or time is shown |
| M3-16 | Choose and implement the `RateProvider` for indicative local-currency equivalents across currencies, with USD-only fallback and disclosure | app | P0 | TODO | M0-05 | Source documented; labeled indicative in UI |
| M3-17 | Accessibility pass (keyboard, labels, contrast) | app | P1 | IN PROGRESS | M3-13 | Audit checklist passed |
Expand Down Expand Up @@ -525,6 +525,7 @@ Newest first. One entry per PR. Required for every contribution (see §2).

| Date | PR / ref | Repo | Rows touched | Summary |
|---|---|---|---|---|
| 2026-10-08 | `feat/m3-14-fragment-leak-test` | app | DONE: M3-14 | The security headers, strict CSP, and no-third-party-scripts work was already built (`proxy.ts`, `lib/security/csp.ts`); the row's actual "Done when" (an automated test confirming the claim-link fragment never appears in any network request) didn't exist, and `lib/security/csp.test.ts` only unit-tests the CSP string builder, not real runtime behavior. Added `e2e/claim-fragment-privacy.spec.ts`: loads `/claim/11` (a real, already-existing testnet lock) with a real-shaped fragment, captures every outbound request for the page's lifetime (URL, body, headers), and fails if the reference or salt appears anywhere. Configured `playwright.config.ts` with a `webServer` (`pnpm build && pnpm start`, since `/claim/*`'s CSP differs between dev and production) and a `baseURL`, neither of which existed before. Verified the test can actually fail, not just trivially pass: temporarily injected a real leak into `useClaim.ts` (a stray `fetch` echoing the reference and salt), confirmed the test caught it with a clear diagnostic, then reverted (`git diff` confirms byte-identical to before) and confirmed a clean pass on the real code. Also caught and fixed two test-design bugs along the way: an invalid (non-`generateSalt()`-shaped) test salt silently made `parseClaimLink` throw before any of the code under test ran, and `waitForLoadState("networkidle")` never resolves on this page (it polls), so switched to a fixed wait. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (108/108), `pnpm build`, and the E2E test itself, run manually multiple times. **Not done here**: wiring this into CI. `.github/workflows/e2e.yml` is marked `DRAFT (ask-first: CI)` and currently just echoes a placeholder; actually running `pnpm e2e` there needs a human to approve editing the workflow file, per `AGENTS.md`'s CI/CD ask-first rule. No new secrets needed (all required env vars are the existing public `NEXT_PUBLIC_*` testnet config) |
| 2026-10-08 | `docs/m3-06-testnet-verification` | app | M3-06 (status unchanged, `IN PROGRESS`) | Attempted the owner's ask to verify `/send` by driving the live app with a real testnet wallet. Got substantially further than "can't automate a browser extension": `@creit.tech/stellar-wallets-kit`'s Albedo option is web-based (no extension) and exposes "provide secret key directly," reachable via Playwright against the real `kinlock-app.vercel.app/send` all the way to the secret-entry field. Writing the secret into any file or process for that automation was blocked outright by the harness's credential-materialization check, including after the owner pasted it directly into the conversation; did not attempt to work around that. Fell back to proving the layer underneath instead: called the deployed contract's `create_lock` directly via `stellar contract invoke` (using a CLI-keystore identity by name, so no secret was ever materialized by this agent), from a freshly generated, friendbot-funded sender account with a real Circle testnet-USDC trustline and balance (owner completed the faucet's human-verification step). Simulated first, then submitted for real: lock id `11` created on the live testnet contract (tx `0b65ec01b50052bac645242c9d375151c4a2b7517856340504d5b7aa46d2042a`, verifiable on stellar.expert), 2 USDC transferred to the contract, correct payee/payout snapshot. Confirmed the indexer picked it up correctly (`/locks/11` matches the submitted transaction exactly) and that the live app's own `/locks/11` and `/claim/11` pages render it (`200`, real chain data). This proves the contract and SDK path End to end on testnet, the thing `/send`'s own code depends on, but does **not** prove `/send`'s own `useSendForm.ts` → wallet-kit → `createLock()` code path specifically, since the actual signing step was bypassed via direct CLI rather than driven through the app. Narrowing what's left for `M3-06`: a human completing the Albedo "provide secret key" step themselves in their own browser against the live site, or a CI-sanctioned signing approach for `M3-19` (still an open, deferred decision) |
| 2026-10-08 | `feat/m3-21-indexer-lag-notice` | app | DONE: M3-21 | Audited each of the row's five named states against the actual message keys and error-mapping code (not assumed): **trustline failure** (claim/release side: `claim-logic.ts`'s `CONTRACT_ERRORS` plus `errorTx`'s "can't receive this asset yet" phrasing; send side: `preflight.payout_trustline_authorized.*`, already specific per failure mode), **expired** (`claim.errorExpired`, `lock.refundAvailable.Expired`), **revoked** and **suspended** (`claim.errorPayeeNotActive` via the contract's `PayeeNotActive` error; `preflight.payee_active.revoked`/`.suspended`; `lock.refundAvailable.Revoked`/`.SuspendedTimeout`) were all already clear and, per the SDK's own preflight doc comment, chain-backed where it matters (`payee_active` and `payout_trustline_authorized` are blocking chain checks, never indexer-sourced, so none of this is at risk from indexer lag). **Indexer lag** was the one genuinely missing piece: added `indexerLagging()` to `lib/indexer/index.ts` (reads the indexer's own `/health` status, defaulting to "possibly stale" on any failure so a broken health check can't silently hide a real lag) and a shared `<LagNotice>` component, shown on `/send`, `/request`, and `/payee` when the indexer reports anything other than `ok`. Purely informational, never blocking: every actual money decision already re-reads chain state regardless. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (108/108, 5 new in `lib/indexer/index.test.ts`), `pnpm build`; confirmed against the real indexer (currently healthy, `lagLedgers: 1`, correctly shows no notice) that the three pages still render and list payees correctly with the added parallel health fetch |
| 2026-10-08 | `feat/payee-dashboard` | app | DONE: M3-11 | Built `/payee`, replacing its `PageStub`. Connect a wallet; `matchingPayeeIds` (new, `payee-logic.ts`) finds every registered payee this wallet is the payout address for, by matching against the already-fetched indexer payee list (no new chain or indexer call needed for that step). For each match, lists its locks via a new `listLocks(payeeId, state?)` in `lib/indexer/index.ts`, filterable by status (server-side, re-queries the indexer per change) and by one specific payment: pasting a claim link runs the exact same `parseClaimLink` + `computeRefHash` check `/claim/[id]` already uses, entirely client-side (hard rule 5: the reference and salt never leave the browser; only `ref_hash` is ever compared). `listLocks` is callable from the browser too (same-origin, via `next.config.ts`'s existing `/locks` rewrite), unlike `listPayees` which is server-only, since the dashboard doesn't know which payeeId to ask for until a wallet connects client-side. Each row links to `/claim/[id]` for the payee's actual release/decline actions; nothing here moves money. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (103/103, 9 new in `payee-logic.test.ts`), `pnpm build` all green; confirmed the `/locks` rewrite returns real, already-created testnet locks for a known payeeId with the exact shape `listLocks` expects (`curl` against a local build with the real indexer URL); visually verified the pre-connect page renders cleanly with no console errors. Not independently verified: the post-wallet-connect render with real data, since driving a real wallet extension isn't automatable (same limitation already accepted for `/send`'s wallet step this session) |
Expand Down
54 changes: 54 additions & 0 deletions e2e/claim-fragment-privacy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* [sec] The claim-link fragment (#r=<reference>&s=<salt>) must never reach a server, log,
* analytics tool, error tracker, or third-party script (hard rule 5, AGENTS.md §3). This is the
* automated check roadmap M3-14 asks for: load /claim/[id] with a real fragment, capture every
* outbound network request the page makes for its whole lifetime, and fail if the reference or
* salt shows up anywhere in a URL, request body, or request header.
*
* Browsers never send the fragment in the initial navigation request by design; the real risk
* this guards against is a later JS-initiated request (an RPC call, an indexer call, a third-party
* script, an error-tracker payload) echoing `window.location.href` or similar. The request
* listener below is attached before navigation, so it also covers the initial request.
*
* Lock 11 is a real lock on the testnet contract (created 2026-10-08, verifying roadmap M3-06);
* any Open lock id would do, since this test never checks whether the reference is valid.
*/
import { buildClaimLink, generateSalt } from "@kinlock/sdk";
import { expect, test } from "@playwright/test";

const SECRET_REFERENCE = "e2e-fragment-privacy-check-reference";
// Must be a real generateSalt()-shaped value: parseClaimLink (and so the app's own fragment
// handling) rejects anything else before ever reaching the code this test is trying to exercise.
const SECRET_SALT = generateSalt();
const LOCK_ID = "11";

test("the claim-link fragment never appears in any network request", async ({ page }) => {
const leaks: string[] = [];
const containsSecret = (text: string) =>
text.includes(SECRET_REFERENCE) || text.includes(SECRET_SALT);

page.on("request", (request) => {
const url = request.url();
if (containsSecret(url)) leaks.push(`URL: ${url}`);

const postData = request.postData();
if (postData && containsSecret(postData)) leaks.push(`BODY (${url}): ${postData}`);

for (const [key, value] of Object.entries(request.headers())) {
if (containsSecret(value)) leaks.push(`HEADER ${key} (${url}): ${value}`);
}
});

const link = buildClaimLink("http://localhost:3000", {
lockId: BigInt(LOCK_ID),
reference: SECRET_REFERENCE,
salt: SECRET_SALT,
});
await page.goto(link.replace("http://localhost:3000", ""));
await page.waitForSelector("main dl", { timeout: 10_000 });
// A fixed window rather than networkidle: the page polls (wallet/RPC checks), so network
// activity never truly goes idle; this still gives any delayed request time to fire.
await page.waitForTimeout(2000);

expect(leaks).toEqual([]);
});
13 changes: 12 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import { defineConfig } from "@playwright/test";

export default defineConfig({ testDir: "./e2e" });
/** Tests run against a real production build, since /claim/*'s CSP differs by NODE_ENV
* (proxy.ts allows 'unsafe-eval' only in dev); a dev-mode run wouldn't test the real policy. */
export default defineConfig({
testDir: "./e2e",
webServer: {
command: "pnpm build && pnpm start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
use: { baseURL: "http://localhost:3000" },
});
Loading