Files
relicario/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md
adlee-was-taken c6abcf8da7 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
2026-06-25 23:22:41 -04:00

17 KiB
Raw Blame History

Org Write Implementation Plan (spike-gated)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. Task 1 is a GO/NO-GO spike — do not start Tasks 2+ until it passes.

Goal: Let org members add, edit, and delete org items from the extension — via ed25519-signed commits the org pre-receive hook accepts.

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.

Global Constraints

  • 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 25 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. 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 Items 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.

File Structure

  • (spike) docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md (new) — the experiment + its result.
  • extension/src/service-worker/git-host.ts — add commitSigned(files, message, sign) to the interface.
  • extension/src/service-worker/gitea.ts / github.ts — implement commitSigned over the Git Data API with an attached SSH signature.
  • extension/src/service-worker/org-vault.tsorgAddItem / orgUpdateItem / orgDeleteItem (encrypt with org handle, collection-scoped path, manifest update, signed commit).
  • extension/src/service-worker/router/org-handlers.ts, shared/messages.tsorg_add_item / org_update_item / org_delete_item.
  • extension/src/popup/components/item-list.ts, item-detail.ts, item-form.ts — un-hide write affordances in org context; collection picker on add.
  • Tests: extension/src/service-worker/__tests__/org-write.test.ts; component tests for the org write affordances.

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

This is a spike, not a TDD cycle. The deliverable is a written GO/NO-GO with evidence.

  • Step 1: Set up a throwaway org repo + hook. On a local Gitea, create a repo, install relicario-server generate-org-hook as the pre-receive hook, and register a test device's ed25519 public key in members.json (use relicario org init/add-member from the CLI to bootstrap a valid org repo).

  • Step 2: Construct and sign a commit object in a Node/SW-like harness. Build a canonical git commit object (tree + parent + author/committer) for a small items/<slug>/<id>.enc change, sign the commit payload with the device key via sign_for_git (export it the way the SW would call WASM), and format the SSH signature into the commit (gpgsig-style SSH signature block).

  • Step 3: Push it via the Gitea Git Data API. POST /git/blobsPOST /git/treesPOST /git/commits (with the signature field if Gitea supports it; otherwise the raw signed commit object) → PATCH /git/refs/heads/{branch}. Record the exact API shape that carries the signature.

  • Step 4: Verify server-side. Confirm the pushed commit passes git verify-commit <sha> on the server AND is accepted by relicario-server verify-org-commit. Repeat Steps 24 against GitHub (create-commit signature field).

  • Step 5: Record the verdict. Write the spike doc with: GO/NO-GO per host, the exact Git Data API call sequence that worked (or the failure mode), and any constraints (e.g., committer identity must match). Commit it.

git add docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md
git commit -m "spike: org signed-commit push via host Git Data API — <GO|NO-GO> result"

Decision gate: GO on at least one host → continue to Task 2 (ship write for the passing host(s), note any host-specific limitation). NO-GO on both → update the spec + STATUS.md, ship org read only, stop here.


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:

  • 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) -> { 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).

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.

  • Step 1: Write failing tests. (a) sshsig.test.tsbuildSshSig 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 they failcd extension && npx vitest run src/service-worker/__tests__/sshsig.test.ts src/service-worker/__tests__/commit-signed.test.ts → FAIL (modules undefined).

  • Step 3: Implementsshsig.ts (WebCrypto SHA-512 + byte assembly), git-push.ts (minimal in-memory fs; cloneaddcommit({onSign})push), add commitSigned to GitHost + thin per-host wrappers passing auth/URL.

  • Step 4: Run to verify they pass — same vitest command → PASS.

  • Step 5: /security-review the push path, then type-check + commitcd 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.

Live residual (not a merge blocker): one real push to git.adlee.work + github.com with tokens, tracked as a manual post-merge check.


Task 3: Org write SW handlers

Files:

  • Modify: extension/src/service-worker/org-vault.ts, router/org-handlers.ts, router/popup-only.ts, shared/messages.ts
  • Test: extension/src/service-worker/__tests__/org-write.test.ts

Interfaces:

  • Consumes: commitSigned (Task 2); the org OrgHandleState (Plan 1); wasm.item_encrypt, wasm.manifest_encrypt.

  • Produces: SW messages org_add_item { collection, item }, org_update_item { id, item }, org_delete_item { id }. Each encrypts with the org handle, writes the collection-scoped items/<slug>/<id>.enc AND the updated manifest.enc in ONE signed commit, and refuses writes to ungranted collections client-side (the hook is the backstop).

  • Step 1: Write the failing test

test('org_add_item refuses a collection the member is not granted', async () => {
  const state = { ...orgStateWithGrants(['prod-infra']) };
  const resp = await handleOrgAddItem({ collection: 'secret-ops', item: fakeLogin }, state);
  expect(resp).toEqual({ ok: false, error: 'collection_not_granted' });
});

test('org_add_item writes item + manifest in one signed commit to the granted path', async () => {
  const state = { ...orgStateWithGrants(['prod-infra']) };
  const commit = vi.spyOn(state.host, 'commitSigned').mockResolvedValue();
  await handleOrgAddItem({ collection: 'prod-infra', item: fakeLogin }, state);
  const [files] = commit.mock.calls[0];
  expect(files.map((f: any) => f.path).sort()).toEqual(['items/prod-infra/' + expect.any(String), 'manifest.enc'].sort());
});
  • Step 2: Run to verify it fails

Run: cd extension && npx vitest run src/service-worker/__tests__/org-write.test.ts Expected: FAIL — handlers undefined.

  • Step 3: Implement the three handlers in org-vault.ts (encrypt, collection-scoped path, manifest update, single commitSigned), wire the three messages (union + POPUP_ONLY_TYPES + dispatch), and the grant check.

  • Step 4: Run to verify it passes

Run: cd extension && npx vitest run src/service-worker/ Expected: PASS (org read + write + personal all green).

  • Step 5: Type-check + commit

Run: cd extension && npm run build:all

git add extension/src/service-worker/org-vault.ts extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts extension/src/service-worker/__tests__/org-write.test.ts
git commit -m "feat(ext/sw): org_add/update/delete_item via signed commits"

Task 4: Org write UI

Files:

  • Modify: extension/src/popup/components/item-list.ts, item-detail.ts, item-form.ts
  • Test: extension/src/popup/components/__tests__/item-form.test.ts (org additions)

Interfaces:

  • Consumes: org_add_item/org_update_item/org_delete_item; PopupState.orgCollections, PopupState.orgOffline.

  • Produces: in org context — the "+ new item" button reappears (Plan 2 hid it) with a granted-collection picker; edit + delete in the org item detail; all write affordances disabled when orgOffline.

  • Step 1: Write the failing test

test('org add form requires a granted collection and sends org_add_item', async () => {
  vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgOffline: false,
    orgCollections: [{ slug: 'prod-infra', display_name: 'Prod' }] } as any);
  const send = vi.spyOn(state, 'sendMessage').mockResolvedValue({ ok: true, data: {} } as any);
  await renderItemForm(document.createElement('div'), { type: 'Login' });
  // fill + pick collection 'prod-infra' + save → assert message
  // ...
  expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'org_add_item', collection: 'prod-infra' }));
});

test('write affordances are disabled when org is offline', async () => {
  vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgOffline: true } as any);
  const el = document.createElement('div'); await renderItemDetail(el, fakeItem);
  expect(el.querySelector('[data-action="edit"]')?.hasAttribute('disabled')).toBe(true);
});
  • Step 2: Run to verify it fails

Run: cd extension && npx vitest run src/popup/components/__tests__/item-form.test.ts Expected: FAIL — no org collection picker / no save routing / not disabled offline.

  • Step 3: Implement — in org context, the save path sends the org_* messages (route via a small helper paralleling Plan 2's messageForList), add a collection <select> (from orgCollections) to the add form, and gate edit/delete/add on !orgOffline.

  • Step 4: Run to verify it passes

Run: cd extension && npx vitest run src/popup/components/ Expected: PASS.

  • Step 5: Type-check + commit

Run: cd extension && npm run build:all

git add extension/src/popup/components/item-list.ts extension/src/popup/components/item-detail.ts extension/src/popup/components/item-form.ts extension/src/popup/components/__tests__/item-form.test.ts
git commit -m "feat(ext): org write UI — collection picker, edit/delete, offline-gated"

Task 5: Org write acceptance tests

Files:

  • Create/extend: extension/src/service-worker/__tests__/org-write.test.ts

  • Step 1: Write the acceptance tests — (a) a write produces a commitSigned call whose commit the org hook contract would accept (assert a signature is attached); (b) an ungranted-collection write is refused client-side; (c) every org write touches BOTH the item path and manifest.enc; (d) offline context blocks writes before any network call.

  • Step 2: Run them

Run: cd extension && npx vitest run src/service-worker/__tests__/org-write.test.ts Expected: PASS.

  • Step 3: Full suite + commit

Run: cd extension && npx vitest run && npm run build:all

git add extension/src/service-worker/__tests__/org-write.test.ts
git commit -m "test(ext/sw): org write acceptance — signature, grants, dual-write, offline"

Notes

  • The spike (Task 1) is the project risk concentrated into one day. Treat a NO-GO as a legitimate, value-preserving outcome: org read (Plans 1+2) + the entire key-file lift (Plans 4+5) still ship in v0.9.0.
  • Committer identity: the org hook attributes the audit entry to the verified signing key; make sure the commit's committer/author and the signature key are consistent with the member's members.json record (the spike must confirm the hook's matching rule).