Files
relicario/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md
adlee-was-taken 74cee8ac67 docs(plans): v0.9.0 implementation plans — 5 streams across 2 specs
Full-TDD per-stream plans for the v0.9.0 multi-agent train:
- org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session +
  org config + grant-filtered manifest read.
- org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner.
- org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write
  handlers + UI. Spike-gated; NO-GO ships read-only.
- keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint +
  WASM bindings + CLI init/unlock --key-file.
- keyfile-ext-positioning (B3+B4): setup container choice + unlock + the
  README/DESIGN/CRYPTO/FORMATS positioning pivot.

Cross-plan contracts pinned and self-reviewed for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
2026-06-21 09:35:44 -04:00

238 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
- Manifest mutation writes BOTH `items/<slug>/<id>.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/<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/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 <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.
```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 — <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: `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<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.
- [ ] **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/<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**
```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 `<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`
```bash
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`
```bash
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).