# 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:** 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. **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 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. - 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.ts` — `orgAddItem` / `orgUpdateItem` / `orgDeleteItem` (encrypt with org handle, collection-scoped path, manifest update, signed commit). - `extension/src/service-worker/router/org-handlers.ts`, `shared/messages.ts` — `org_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 via the host Git Data API **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//.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/blobs` → `POST /git/trees` → `POST /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 ` on the server AND is accepted by `relicario-server verify-org-commit`. Repeat Steps 2–4 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. ```bash 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 — 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: `commitSigned` GitHost method **Files:** - Modify: `extension/src/service-worker/git-host.ts`, `gitea.ts`, `github.ts` - Test: `extension/src/service-worker/__tests__/git-host-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. - [ ] **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) ```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 2: Run to verify it fails** Run: `cd extension && npx vitest run src/service-worker/__tests__/git-host-signed.test.ts` Expected: FAIL — `commitSigned` not defined. - [ ] **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 it passes** 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" ``` --- ### 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//.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** ```ts 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` ```bash 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** ```ts 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 `