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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha
This commit is contained in:
@@ -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/<slug>/<id>.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/<slug>/<id>.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 — <GO|NO-GO
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `commitSigned` GitHost method
|
||||
### Task 2: universal `commitSigned` — isomorphic-git receive-pack push (RE-SCOPED 2026-06-25)
|
||||
|
||||
> **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<void>` 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<void>` 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user