From c6abcf8da78cd1afd4937beaa4c3ef3068b13703 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:22:41 -0400 Subject: [PATCH] docs(plan-c): re-scope to universal isomorphic-git receive-pack push Mini-spike (isomorphic-git) proved the universal write path end-to-end with no live creds: Tier 1 commit({onSign}) embeds our SSHSIG -> git verify-commit + verify-org-commit both PASS; Tier 2 push over git-receive-pack to a bare repo running the real org pre-receive hook ACCEPTS a member-signed commit and REJECTS a non-member one. - Spike doc: + Addendum recording the isomorphic-git Tier 1/2 results + Task 2 implications (in-memory fs only, http/web, onAuth Basic, no new WASM export). - Plan doc: architecture re-scoped (Gitea has no write Git Data API at any version -> single universal receive-pack path for both hosts); Task 1 marked DONE/GO; Task 2 rewritten as the universal commitSigned; Dev-A OrgManifest contract + Tasks 3-5 HELD-on-A/B recorded. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha --- .../plans/2026-06-20-v0.9.0-org-c-write.md | 65 ++++++++----------- .../2026-06-20-org-signed-commit-spike.md | 19 +++++- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md index 83aab00..eba3547 100644 --- a/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md @@ -4,7 +4,7 @@ **Goal:** Let org members add, edit, and delete org items from the extension — via ed25519-signed commits the org pre-receive hook accepts. -**Architecture:** The org hook rejects unsigned commits, and the extension's Contents-API write path produces unsigned commits. So org writes go through the host **Git Data API** (blob → tree → **signed** commit → update-ref), signing the commit object with `sign_for_git` (already in WASM, unused today). `gitea.ts`/`github.ts` already use the Git Data API for large attachments, so this extends existing machinery. Whether the host API preserves a caller-supplied SSH signature through to `git verify-commit` is unproven — Task 1 proves it before anything else is built. +**Architecture (RE-SCOPED 2026-06-25 after Task 1 + the mini-spike — see `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md`):** The org hook rejects unsigned commits, and the extension's Contents-API write path produces unsigned commits. Task 1 found that **Gitea has no write Git Data API at any version** (the `/git` group is GET-only; confirmed live on 1.25.5), so the originally-planned "host Git Data API" path is a dead end for our primary host. **Decision (user): a single universal write path for both Gitea and GitHub** — a native `git-receive-pack` smart-HTTP **packfile push** driven by **isomorphic-git** in the service worker. The commit is signed in the SW: `sign_for_git` (raw ed25519, already in WASM) wrapped in **SSHSIG framing** (`sha512` digest → `SSHSIG‖git‖sha512‖H` blob → sign → armored `gpgsig`) — **no new WASM export**. The mini-spike proved end-to-end (no live creds) that isomorphic-git `commit({onSign})` embeds the SSHSIG cleanly (`git verify-commit` + `verify-org-commit` both pass) and that an isomorphic-git `push` over receive-pack to a bare repo running the real pre-receive hook is ACCEPTED for a member and REJECTED for a non-member. **Hook matching is by signing-key fingerprint → `members[].ed25519_pubkey`; committer/author text is free-form** (set it to the member's identity for audit hygiene only). **Tech Stack:** TypeScript (extension SW + UI), vitest; `relicario-server` (the hook, for the spike); a live Gitea (and GitHub) repo for the spike. @@ -12,8 +12,9 @@ - Release target: v0.9.0. - **Task 1 gates the rest.** If the spike fails on both hosts, STOP: record the result in `docs/superpowers/specs/2026-06-20-extension-org-gui-design.md` and `STATUS.md`, ship org **read** (Plans 1+2) for v0.9.0, and move org write to a follow-up. Do not build Tasks 2–5 against a push path the server will reject. -- Consume Plan 1's context model + Plan 2's read UI. Org writes operate on the current org context. -- Manifest mutation writes BOTH `items//.enc` AND `manifest.enc` (the personal "both writes" invariant) — both inside ONE signed commit where possible. +- Consume Plan 1's context model + Plan 2's read UI. Org writes operate on the current org context. **Tasks 3-5 (write SW handlers + UI) are HELD until Dev-A + Dev-B merge** (they consume A's context model + B's read surfaces); Task 2 (transport) is independent and built first. +- **Org manifest contract (Dev-A, 2026-06-26):** the org manifest is a DEDICATED `OrgManifest { schema_version, entries: OrgManifestEntry[] }` (NOT personal `ManifestEntry`); `OrgManifestEntry = { id, type, title, tags: string[], modified, trashed_at?, collection: string (REQUIRED) }`. Dev-A exposes BOTH `org_manifest_decrypt` AND `org_manifest_encrypt` over WASM (so Plan C never touches WASM) + mirrors the types in `shared/types.ts`. Org items stay personal `Item`s via `item_encrypt`. So org_add/update/delete = `org_manifest_decrypt` → mutate `entries[]` → `org_manifest_encrypt` → push `item.enc` + `manifest.enc` in ONE signed commit via `commitSigned`. +- Manifest mutation writes BOTH `items//.enc` AND `manifest.enc` (the personal "both writes" invariant) — both inside ONE signed commit. - Org master key stays in the Zeroizing WASM handle; signing uses the device key from `chrome.storage.local.device_private_key`. - Capitalize "Relicario" in prose. @@ -31,7 +32,9 @@ --- -### Task 1: GO/NO-GO spike — signed commit via the host Git Data API +### Task 1: GO/NO-GO spike — signed commit (✅ DONE — GO) + +> **DONE 2026-06-25 (commit `426b82a` + mini-spike addendum).** Verdict: **GO**. Signature mechanism proven (raw `sign_for_git` + SSHSIG framing passes `git verify-commit` + `verify-org-commit`, no new WASM export). Gitea has no write Git Data API at any version → the planned REST transport is dead; the universal path is an isomorphic-git `git-receive-pack` packfile push, **proven end-to-end** (Tier 1 commit+verify, Tier 2 push-through-real-hook). A follow-on **Task 1.5 mini-spike** (isomorphic-git) is folded into the spike doc's Addendum. Full evidence + per-host detail: `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md`. **Files:** - Create: `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md` @@ -57,50 +60,38 @@ git commit -m "spike: org signed-commit push via host Git Data API — **A/B-INDEPENDENT — build now.** Replaces the original "Git Data API" Task 2 (dead on Gitea). ONE transport for both hosts, proven by the mini-spike. **Files:** -- Modify: `extension/src/service-worker/git-host.ts`, `gitea.ts`, `github.ts` -- Test: `extension/src/service-worker/__tests__/git-host-signed.test.ts` +- Add dep: `isomorphic-git` (~68KB gzip, user-approved) in `extension/package.json`. +- Create: `extension/src/service-worker/sshsig.ts` — SSHSIG framing over `sign_for_git`. +- Create: `extension/src/service-worker/git-push.ts` — shared isomorphic-git commit+push engine on an **in-memory fs ONLY** (never lightning-fs/IndexedDB — org repo data must never persist; spec invariant line 231 / A1). +- Modify: `extension/src/service-worker/git-host.ts` (add `commitSigned` to the interface), `gitea.ts` / `github.ts` (host auth + URL only). +- Test: `extension/src/service-worker/__tests__/sshsig.test.ts`, `__tests__/commit-signed.test.ts`. **Interfaces:** -- Consumes: `wasm.sign_for_git(data: Uint8Array)`; device key from `chrome.storage.local`. -- Produces: `commitSigned(files: Array<{ path: string; content: Uint8Array }>, message: string, sign: (payload: Uint8Array) => string): Promise` on the `GitHost` interface — builds one commit containing all `files` via the Git Data API, signs the commit object, pushes per the Task-1 sequence. +- Consumes: `wasm.sign_for_git(data: Uint8Array) -> { signature: base64 }`; device signing pubkey (OpenSSH) + the org `apiToken` from the host config. +- Produces: `commitSigned(files: Array<{ path: string; content: Uint8Array }>, message: string): Promise` on `GitHost` — shallow single-branch clone into an in-memory fs, stage all `files`, ONE commit signed via `onSign` (SSHSIG over isomorphic-git's payload), push over `git-receive-pack`. `sshsig.ts` exports `buildSshSig(payload, signingPubOpenssh, rawSign)` where `rawSign(bytes) = base64decode(sign_for_git(bytes).signature)`. -- [ ] **Step 1: Write the failing test** (mock fetch to the Git Data API endpoints; assert the call sequence + that the commit body includes the signature) +**Build to these mini-spike facts:** +- `onSign` receives the EXACT serialized payload → sign THAT (no timestamp/folding canonicalization risk). +- SSHSIG: `H=sha512(payload)`; signed-blob `"SSHSIG"‖str("git")‖str("")‖str("sha512")‖str(H)`; armored `"SSHSIG"‖u32(1)‖str(pubwire)‖str("git")‖str("")‖str("sha512")‖str(str("ssh-ed25519")‖str(rawsig))`; base64-wrap@70 in `-----BEGIN/END SSH SIGNATURE-----`. (`str(x)` = u32-BE-len‖bytes; `pubwire` = base64-decoded 2nd field of the OpenSSH pubkey line.) +- SW uses `isomorphic-git/http/web`; `host_permissions` ⇒ no CORS proxy. `onAuth → { username, password: apiToken }` (Basic) for both hosts. +- Committer identity is cosmetic to the hook; set it to the member identity for audit hygiene. -```ts -test('commitSigned creates a blob+tree+signed-commit and updates the ref', async () => { - const calls: string[] = []; - globalThis.fetch = vi.fn(async (url: string) => { calls.push(String(url)); return okJson({ sha: 'x' }); }) as any; - const host = new GiteaHost('https://git.example', 'o/r', 'tok'); - await host.commitSigned([{ path: 'items/c/i.enc', content: new Uint8Array([1]) }], 'add', () => 'SSHSIG...'); - expect(calls.some(u => u.includes('/git/blobs'))).toBe(true); - expect(calls.some(u => u.includes('/git/commits'))).toBe(true); - expect(calls.some(u => u.includes('/git/refs/'))).toBe(true); -}); -``` +- [ ] **Step 1: Write failing tests.** (a) `sshsig.test.ts` — `buildSshSig` over a FIXED key+payload equals a golden vector (captured from the proven spike harness) and round-trips through a real `git verify-commit` in a node-side helper. (b) `commit-signed.test.ts` — mock the isomorphic-git http client; assert ONE commit carrying a `gpgsig`, containing exactly the supplied file paths, pushed to `…/git-receive-pack`. -- [ ] **Step 2: Run to verify it fails** +- [ ] **Step 2: Run to verify they fail** — `cd extension && npx vitest run src/service-worker/__tests__/sshsig.test.ts src/service-worker/__tests__/commit-signed.test.ts` → FAIL (modules undefined). -Run: `cd extension && npx vitest run src/service-worker/__tests__/git-host-signed.test.ts` -Expected: FAIL — `commitSigned` not defined. +- [ ] **Step 3: Implement** — `sshsig.ts` (WebCrypto SHA-512 + byte assembly), `git-push.ts` (minimal in-memory fs; `clone`→`add`→`commit({onSign})`→`push`), add `commitSigned` to `GitHost` + thin per-host wrappers passing auth/URL. -- [ ] **Step 3: Implement** — extend the existing Git Data API code (the large-attachment path) into `commitSigned`, following the exact sequence the spike proved. Add `commitSigned` to the `GitHost` interface and both hosts. +- [ ] **Step 4: Run to verify they pass** — same vitest command → PASS. -- [ ] **Step 4: Run to verify it passes** +- [ ] **Step 5: `/security-review` the push path, then type-check + commit** — `cd extension && npm run build:all`, then commit `package.json` + new/changed SW files + tests: `feat(ext/sw): universal commitSigned — isomorphic-git receive-pack packfile push + SSHSIG`. -Run: `cd extension && npx vitest run src/service-worker/__tests__/git-host-signed.test.ts` -Expected: PASS. - -- [ ] **Step 5: Type-check + commit** - -Run: `cd extension && npm run build:all` - -```bash -git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts extension/src/service-worker/__tests__/git-host-signed.test.ts -git commit -m "feat(ext/sw): commitSigned — signed multi-file commit via Git Data API" -``` +**Live residual (not a merge blocker):** one real push to git.adlee.work + github.com with tokens, tracked as a manual post-merge check. --- diff --git a/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md b/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md index 99956a1..442c781 100644 --- a/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md +++ b/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md @@ -1,7 +1,7 @@ # Spike: Org signed-commit push via host APIs — GO/NO-GO - **Date run:** 2026-06-25 (Dev-C, v0.9.0 Plan C / org write) -- **Status:** **COMPLETE — GO** (signature mechanism proven; transport is host-specific, see below) +- **Status:** **COMPLETE — GO** (signature mechanism proven; universal isomorphic-git packfile-push transport proven 2026-06-25 — see **Addendum**) - **Plan:** `docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md` Task 1 - **Spec:** `docs/superpowers/specs/2026-06-20-extension-org-gui-design.md` § "The org-write signing gate" @@ -136,3 +136,20 @@ curl -s https://git.adlee.work/swagger.v1.json | jq '.paths|keys[]|select(test(" curl -s -o /dev/null -w '%{http_code}' \ 'https://git.adlee.work/alee/relicario.git/info/refs?service=git-receive-pack' # 401 (exists) ``` + +## Addendum (2026-06-25): universal isomorphic-git packfile push — PROVEN end-to-end + +**Decision (user):** build org write NOW in v0.9.0 as a **single universal write path for both Gitea and GitHub** — a native `git-receive-pack` smart-HTTP packfile push driven by **isomorphic-git** in the service worker. The GitHub Git Data API + `signature` approach is retired (one path, not two). Rationale: Gitea has no write Git Data API at any version (§ above), and receive-pack is host-agnostic, so it also covers GitHub. + +A follow-on mini-spike validated the isomorphic-git integration, again with **no live-host credentials**: + +- **Tier 1 — signature survives isomorphic-git serialization.** `git.commit({ onSign })` hands the callback the exact serialized commit payload; the callback returns our SSHSIG armor (raw ed25519 over `SSHSIG‖git‖sha512‖H(payload)`, the framing proven above), and isomorphic-git embeds it as `gpgsig`. Result: real `git verify-commit` **PASS** and `relicario-server verify-org-commit` **PASS**. Because `onSign` signs isomorphic-git's *own* payload, the byte-canonicalization hazard (timestamps, header folding) is eliminated by construction. +- **Tier 2 — push through the real hook.** `git.push({ http })` over git smart-HTTP `git-receive-pack` to a local `git-http-backend` bare repo carrying the real `relicario-server generate-org-hook` pre-receive hook: an owner-signed commit was **ACCEPTED** (`refs/heads/main ok`), a commit signed by a non-member was **REJECTED** ("pre-receive hook declined"). The hook gates the push exactly as in production. + +**Implications for Task 2 (now "universal `commitSigned`"):** +- One `commitSigned` for both hosts: in-memory fs + isomorphic-git shallow `clone` → stage files → `commit({ onSign: sshsig })` → `push` over receive-pack. Host difference is only auth + URL. +- SW uses `isomorphic-git/http/web`; the extension's `host_permissions` exempt SW `fetch()` from page CORS (no proxy). Auth via `onAuth → { username, password: token }` (Basic) — Gitea PAT-as-password; a GitHub token works as Basic over HTTPS. +- SSHSIG framing is SW/TypeScript (`sshsig.ts`), calling the existing WASM `sign_for_git` for the one secret operation. Still **no new WASM export**. +- Residual unchanged: one live push to git.adlee.work + github.com with real tokens (transport-only confirmation). + +Mini-spike harness: `iso-spike/spike1.mjs` (commit+verify) and `spike2.mjs` (push through the real hook) in the Dev-C scratchpad.