# Plan 1C-γ₁: Attachments + Document type — design

**Date:** 2026-04-24
**Scope:** Wire the existing Rust attachment-encryption surface into the extension, add the Git host's missing `putBlob` operation (with Git Data API fallback for large blobs), introduce the Document item type that's been a "coming soon" stub since β₁, and surface attachments in both add-flow and view-flow inside the popup.

## Goal

The Rust core has shipped attachment encryption (`attachment_encrypt` / `attachment_decrypt`, exposed in WASM), the manifest already reserves an `attachment_summaries: Vec<AttachmentSummary>` field on each entry, and every typed item form already round-trips an `attachments: Vec<AttachmentRef>` array (currently always empty from the popup's side). What's missing is the extension plumbing: a UI to add/view attachments, a Document item-type form, a way to encrypt+upload the bytes through the service worker, and the missing `GitHost.putBlob` op (with the >900 KB Git Data API fallback that Contents API can't handle because of base64 inflation).

γ₂ (later) will surface the views over already-supported core capabilities: trash, field history, device management, and the attachment-caps UI.

## Non-goals

- Trash view, field history view, device management UI, attachment-caps configuration UI — all γ₂.
- Drag-drop file affordance — file picker only in v1; drag-drop is polish that can land later if usage warrants.
- Multi-file upload at once — single file per pick; reduces edge cases (caps overshoot, partial-upload state) for γ₁.
- Attachment count on item-list rows — γ₁ shows just a `📎` icon when an item has any attachments; the count is deferred (most users won't care about exact count, and it's noise on narrow rows).
- Inline image preview panels in the disclosure body — γ₁ uses thumb-icons (16×16) in the row; click → browser download. The big inline-preview pane was option C earlier and was rejected as overkill for the popup's vertical budget.
- Document type's signature-block image preview at large size — the detail view's gold "signature block" shows a 36×60 thumb plus filename/meta. Full-size in-popup viewing is not provided; user downloads to view full-resolution.

## Visual identity

### Attachments — compact disclosure (locked: pattern A)

Every typed-item form (Login, SecureNote, Identity, Card, Key, TOTP, **Document**) gets an `attachments` disclosure rendered AFTER the type-specific fields and AFTER the existing `custom fields` disclosure. The disclosure header reads:

- Empty: `▸ attachments`
- Populated: `▾ attachments (N)` (with N being the count from `core.attachments.length`)

Disclosure body in **edit mode**:

- One row per existing attachment: `[icon-or-thumb] filename                   12 KB   ×`
- A "+ attach file" button at the bottom, full-width, dashed border (`#30363d`), color `#8b949e` → `#c9d1d9` on hover. Clicking it triggers a hidden `<input type="file">` with no `multiple` attr.
- The `×` removes the attachment from the editing item draft (does NOT delete the underlying blob from the git host until the item is saved — see "lifecycle" below).

Disclosure body in **view mode**:

- One row per attachment, same layout as edit mode, but the action column is `↓` (download) instead of `×`.
- Click the row OR click the `↓` → triggers a browser download of the decrypted attachment via `chrome.downloads.download` (or anchor-tag fallback for Firefox). Browser handles preview for known mime types when the user opens the downloaded file.
- No "+ attach file" button (only available in edit mode).

### Image attachments — thumb-icon column (locked: pattern B)

For attachment rows where `mime_type` starts with `image/`, the leading icon column renders a 16×16 thumbnail of the image instead of the generic `📄` glyph. The thumbnail is generated lazily:

- On disclosure open, image-mime rows fetch their decrypted blob via SW message, create a `URL.createObjectURL(blob)` object URL, and use it as the `<img>` src in the icon column.
- On disclosure close (or item navigation away, or popup close), all created object URLs are `URL.revokeObjectURL`'d in a teardown function. This keeps memory bounded.
- For non-image attachments: render the generic `📄` glyph; no decryption work happens until the user clicks download.

### Document type (locked: pattern C)

The Document type's identifying field is its `primary_attachment` — a single, REQUIRED file. Form composition:

- **title** (required, lowercase label + gold `*` per project polish)
- **primary attachment** (required, gold `*`) — compact-row picker. Empty state: dashed-border `+ attach primary file` button. Filled state: a single row with `[thumb] filename 240 KB ↑ change`. Clicking `↑ change` re-triggers the file picker.
- **notes** (optional, textarea)
- **tags** (optional, comma-separated input — single text input with chip-style display below if implementer wants polish)
- **expires** (optional, MM/YYYY two-input row using the existing pattern from Card's expiry)
- **attachments disclosure** (optional supplementary attachments — same compact-disclosure pattern as other types)

Detail view promotes the primary attachment to a **signature block** (gold left-border `border-left: 3px solid #aa812a`, `#161b22` background, padding 10 px). The signature block contains:

- Left: 48×60 thumbnail (`linear-gradient(135deg, #b88a30, #7c5719)` for non-image; actual decrypted thumb for image-mime)
- Right: filename (font 11 px, `#f1cf6e`, weight 600), meta line below in `#8b949e` showing `size · created` (e.g. `240 KB · 2026-04-12`), and an action line `↓ download · 🔍 preview` where `🔍 preview` only appears for image-mime attachments and triggers an inline expanded preview within the signature block (toggle).

Below the signature block, the standard typed-rows render (notes, tags, expires) and finally the supplementary `attachments` disclosure if any exist.

### Item-list attachment indicator (locked: 2c)

Item-list rows currently render `[type-icon] [title] [favorite-star]`. After γ₁, items with at least one attachment also show a small `📎` glyph just before the favorite-star slot. No count is rendered. The row template change is one optional span; styling reuses the existing muted-text class.

For Document items, the `📎` is implicit (every Document has at least the primary attachment), but we still render it for consistency.

## Architecture

### Upload pipeline

End-to-end flow when the user clicks "+ attach file":

1. Hidden `<input type="file">` opens browser file picker.
2. On change, popup reads file via `FileReader.readAsArrayBuffer` (or just `file.arrayBuffer()`).
3. Popup checks the bytes against `VaultSettings.attachment_caps.per_attachment_max_bytes` — if exceeded, show toast `"file too large (X MB / cap is Y MB)"` and abort. (Caps default to undefined / no limit if `attachment_caps` is empty; γ₂ adds the UI to set them.)
4. Popup sends `{type: 'upload_attachment', itemId: <id>, filename, mimeType, bytes: <ArrayBuffer>}` to SW. The bytes go via `chrome.runtime.sendMessage` structured-clone — Chrome's per-message limit is generously above any reasonable attachment size we're targeting.
5. SW receives in `popup-only.ts`:
   - Verifies sender (popup-only, per existing router pattern)
   - Calls WASM `attachment_encrypt(sessionHandle, bytes)` → returns `{id, bytes: encryptedBytes}` where `id = sha256(plaintext)`
   - Constructs the storage path: `attachments/<id>.bin` (within the configured vault root path on the git host)
   - Calls `gitHost.putBlob(path, encryptedBytes, message)` — the new operation on `GitHost` interface
   - Updates the item's `attachments` array to include the new `AttachmentRef { id, filename, mime_type, size: plaintextLen, created: now() }`
   - Updates the manifest's per-entry `attachment_summaries` array
   - Persists both updated item.json and manifest.json via `gitHost.writeFile` (Contents API; small files)
   - Returns `{ok: true, attachment: AttachmentRef}` to the popup
6. Popup updates its in-memory item draft (or re-fetches the item from SW), re-renders the attachments disclosure with the new row, hides loading state.

If any step fails, SW returns `{ok: false, error: 'upload_failed', detail: '...'}` and popup shows toast `"upload failed: <reason>"` without modifying the draft.

### `GitHost.putBlob` — interface extension

The `GitHost` interface (`extension/src/service-worker/git-host.ts`) currently has 4 ops: `readFile`, `writeFile`, `deleteFile`, `listDir`. γ₁ adds:

```ts
/// Write an opaque binary blob to the repo. Unlike writeFile, this is
/// optimized for large attachments — implementations choose between
/// Contents API (small) and Git Data API (large) based on byte length.
/// Returns the path that was written (same as input, for chaining).
putBlob(path: string, content: Uint8Array, message: string): Promise<string>;

/// Read an opaque binary blob from the repo. Same semantics as readFile
/// for small files; for large files the implementation may use the
/// Git Data API to fetch the blob by its sha rather than the contents
/// endpoint.
getBlob(path: string): Promise<Uint8Array>;

/// Delete a blob from the repo. For now identical to deleteFile, but
/// kept distinct so future fallback paths (Git Data API) have a hook.
deleteBlob(path: string, message: string): Promise<void>;
```

`getBlob` mirrors `readFile` but is named distinctly so we can later add streaming/chunked reads if needed. For γ₁ it's just a thin wrapper over `readFile` with no fallback (Contents API GET works for files up to 100 MB on GitHub; we read the encrypted bytes back as base64-decoded Uint8Array).

`deleteBlob` is also a thin wrapper for now; kept distinct for symmetry with putBlob.

### `putBlob` fallback strategy

In `GitHubHost.putBlob` and `GiteaHost.putBlob`:

```
const THRESHOLD_BYTES = 900 * 1024;  // 900 KB pre-base64

if (content.length <= THRESHOLD_BYTES) {
  // Use existing writeFile path (Contents API PUT with base64-encoded content)
  await this.writeFile(path, content, message);
  return path;
}

// Git Data API fallback for large blobs:
// 1. POST /repos/{owner}/{repo}/git/blobs  → returns blob SHA
// 2. GET  /repos/{owner}/{repo}/branches/{branch}  → get current commit SHA + tree SHA
// 3. POST /repos/{owner}/{repo}/git/trees  → create new tree with blob added at path, base_tree = current tree
// 4. POST /repos/{owner}/{repo}/git/commits  → create commit with new tree, parent = current commit
// 5. PATCH /repos/{owner}/{repo}/git/refs/heads/{branch}  → fast-forward branch to new commit
```

Both GitHub and Gitea expose these endpoints with the same shape (Gitea modeled itself after GitHub). One concrete divergence: the Gitea v1 API uses `/api/v1/repos/...` prefix; GitHub uses `/repos/...`. The existing `gitea.ts` and `github.ts` already encapsulate this divergence in their constructors. The fallback path adds 4 more endpoint calls per impl; we add them as private helper methods (`createGitBlob`, `getRefSha`, `createGitTree`, `createGitCommit`, `updateRef`) and orchestrate them in `putBlob`.

The threshold `900 * 1024` is a constant exported from `git-host.ts` so both implementations agree. 900 KB pre-base64 → ~1.2 MB after base64 → safely under GitHub's 1 MB Contents API soft-limit and well under Gitea's tolerance.

### Manifest update flow

After every attach/detach, both the item file (`items/<id>.json`) and the manifest file (`manifest.json`) need to be updated atomically-as-possible:

- Item file: re-encrypt the entire item with new `attachments` array
- Manifest: re-encrypt the entire manifest with the entry's `attachment_summaries` field updated

Two separate `writeFile` calls (the manifest is small, item file is small — both well under threshold). We accept the brief window where item is updated but manifest isn't yet: in the worst case, the popup sees the item with the new attachment but the manifest list view doesn't show the `📎` indicator until the next sync. This is acceptable — the user is the only one writing, and the popup will eagerly re-fetch the manifest after the upload completes.

The blob itself (`attachments/<id>.bin`) is written FIRST, so even if the item/manifest writes fail, the blob exists in the repo (and a subsequent retry can reattach it). Orphaned blobs (referenced by no item) are tolerable — γ₂'s attachment-caps UI can include a "purge orphans" action later.

### Attachment lifecycle

**Add (during item edit):**
1. User clicks "+ attach file" → file picker → chooses file
2. Popup sends `upload_attachment` to SW with the bytes
3. SW encrypts + putBlobs the encrypted bytes immediately (synchronous from the user's perspective; toast "uploading..." → "✓ added" or "✕ failed")
4. SW returns the `AttachmentRef`; popup adds it to its in-memory item draft
5. The item itself isn't saved until the user clicks "save" on the form. So between step 4 and the user clicking save, the blob exists in the repo but no item references it. If the user clicks "cancel" instead of save, the orphaned blob stays (will be garbage-collected by γ₂'s purge-orphans action).

**Remove (during item edit):**
1. User clicks `×` on an existing attachment row in the disclosure
2. Popup removes the row from the in-memory draft (visual immediate)
3. The blob is NOT deleted yet; on form save, the SW compares the saved item's attachments vs. the original and `deleteBlob`s any that were removed
4. If user clicks "cancel" instead of save, the original item (and its blobs) are unchanged

**Save with new attachments:**
1. User clicks "save"
2. Popup sends the updated item to SW (existing flow); SW writes item.json + manifest.json
3. Any deferred deletes (from removes during edit) are processed: SW iterates the original-attachments-minus-current-attachments set and `deleteBlob`s each
4. Failures are best-effort: a failed delete doesn't block the save; orphaned blobs are tolerable

**Download (in detail view):**
1. User clicks attachment row
2. Popup sends `{type: 'download_attachment', itemId, attachmentId}` to SW
3. SW reads the blob via `getBlob`, decrypts via `attachment_decrypt(sessionHandle, encryptedBytes)`
4. SW returns the decrypted bytes (ArrayBuffer)
5. Popup creates a Blob with the original `mime_type`, generates an object URL via `URL.createObjectURL`, triggers download via `chrome.downloads.download({url, filename})`, then revokes the URL after a brief delay

**Image thumb rendering (in detail view):**
- Same as download except step 5 sets the object URL as the `<img>` src instead of triggering a download
- Object URLs are tracked in a per-disclosure registry and revoked on disclosure close / navigation

### Caps enforcement (γ₁ enforces, γ₂ configures)

Caps live in `VaultSettings.attachment_caps` (β₂ shipped the schema; the UI is γ₂). γ₁ reads the four caps and enforces:

- `per_attachment_max_bytes`: rejected at popup before sending to SW (cheap; fails fast)
- `per_item_max_count`: count of attachments on the item (not bytes); rejected at popup
- `per_vault_soft_cap_bytes`: sum of plaintext sizes across all items (computed from manifest summaries); shows warning toast but allows upload
- `per_vault_hard_cap_bytes`: same sum; hard reject at popup

If any cap is `undefined`, no limit is enforced for that level. γ₂ will surface the configuration UI; in γ₁ users without explicit caps get unlimited attachments (modulo the implementation's practical limits — large blobs work via Git Data API, but uploading a 50 MB file will be slow).

## Files affected

### Rust core (likely no changes)

The Rust core already has everything we need:
- `attachment.rs`: `AttachmentRef`, `AttachmentSummary`, `EncryptedAttachment`, `encrypt_attachment`, `decrypt_attachment`
- `item_types/document.rs`: `DocumentCore { filename, mime_type, primary_attachment }`
- `manifest.rs`: per-entry `attachment_summaries: Vec<AttachmentSummary>`
- WASM exports: `attachment_encrypt`, `attachment_decrypt`

If a gap surfaces during implementation (e.g. missing helper), the plan will note it and add a small Rust task. But the design assumes the Rust surface is complete.

### Service worker

- `extension/src/service-worker/git-host.ts` — extend `GitHost` interface with `putBlob`, `getBlob`, `deleteBlob`. Export `BLOB_THRESHOLD_BYTES = 900 * 1024`.
- `extension/src/service-worker/github.ts` — implement `putBlob` (with Git Data API fallback), `getBlob`, `deleteBlob`. Add 5 private helper methods for the Git Data API endpoints.
- `extension/src/service-worker/gitea.ts` — same as github.ts. Endpoints have `/api/v1/` prefix; payload shapes are identical.
- `extension/src/service-worker/router/popup-only.ts` — add 2 message handlers: `upload_attachment` and `download_attachment`. Both wired to existing `popup_only` sender check.
- `extension/src/service-worker/vault.ts` — add helpers: `addAttachmentToItem(itemId, attachmentRef)`, `removeAttachmentsFromItem(itemId, idsToRemove)`. Both update item.json + manifest.json.

### Popup

- `extension/src/popup/components/attachments-disclosure.ts` — NEW. Renders the compact disclosure (header + rows + "+ attach file" button). Accepts the item draft, the form mode (`'add' | 'edit' | 'view'`), and an `onChange(attachments)` callback for edit mode. Manages object-URL lifecycle for image thumbs in view mode.
- `extension/src/popup/components/types/document.ts` — NEW. Same shape as other type components (renderForm, renderDetail, save handler). Includes the primary-attachment picker and the signature-block detail rendering.
- `extension/src/popup/components/item-form.ts` — wire up Document case in the dispatcher (currently routes to `renderComingSoon`).
- `extension/src/popup/components/item-list.ts` — add the `📎` indicator span in the row template when `entry.attachment_summaries.length > 0`.
- `extension/src/popup/components/types/{login,secure-note,identity,card,key,totp}.ts` — add `attachmentsDisclosure(...)` call after the custom-fields disclosure in each renderForm. ~3 lines per file.
- `extension/src/popup/styles.css` — add rules for `.attachment-row`, `.attachment-row__thumb`, `.attachment-row__name`, `.attachment-row__meta`, `.attachment-row__action`; `.attachment-add-btn`; `.document-signature-block` (signature-block treatment).

### Tests

- `extension/src/service-worker/__tests__/git-host.test.ts` — NEW. Test putBlob threshold logic (with mocked fetch): small payload uses Contents API; payload >900KB uses Git Data API sequence (5-call mock). Verify failure paths bubble up.
- `extension/src/popup/components/__tests__/attachments-disclosure.test.ts` — NEW. Test render in each mode, +attach triggers file picker, × removes from draft, ↓ triggers download message, image-mime rows lazy-load thumbs, object URLs revoked on close.
- `extension/src/popup/components/types/__tests__/document.save.test.ts` — NEW. Test Document form save: missing primary_attachment shows validation error; valid save sends correct wire format.
- `extension/src/service-worker/router/__tests__/router.test.ts` — extend with 2 cases: `upload_attachment` accepted from popup, rejected from content; same for `download_attachment`.

Estimated test count growth: ~15 new tests (was 128 after gen-UX, target ~143).

## Acceptance

- [ ] Clicking "+ attach file" on any item form opens browser file picker.
- [ ] Picking a file uploads it (encrypted) to the configured git host within ~1-3 seconds for typical files (<1 MB) or up to ~10s for large files (>5 MB).
- [ ] The new attachment appears in the disclosure immediately after upload.
- [ ] Saving the item persists the updated `attachments` array; reopening shows the attachment.
- [ ] In view mode, clicking ↓ downloads the decrypted file to the user's downloads folder.
- [ ] Image-mime attachments show a 16×16 thumb in the icon column (lazily decrypted on disclosure open).
- [ ] Removing an attachment in edit mode + saving deletes the underlying blob from the git host.
- [ ] Item-list rows show `📎` for items with at least one attachment.
- [ ] Document item type's "+ New" entry is no longer "coming soon" — opens the Document form.
- [ ] Document form rejects save when `primary_attachment` is empty (gold required-field treatment).
- [ ] Document detail view renders the gold signature block with thumb + filename + meta + actions.
- [ ] Documents with image primary_attachment offer a `🔍 preview` toggle; clicking expands an inline preview pane within the signature block.
- [ ] putBlob with content >900 KB uses the Git Data API fallback (verified via test with mocked fetch + via manual upload of a >1 MB file to a real test repo).
- [ ] putBlob with content ≤900 KB uses the existing Contents API path (no Git Data API calls).
- [ ] `bun run test` passes (existing 128 + ~15 new = ~143 tests).
- [ ] `bun run build:all` clean for both Chrome and Firefox.
- [ ] `cargo test --workspace` passes (155).
- [ ] `bunx tsc --noEmit` clean.
- [ ] Manual smoke: walk through Login form add+remove attachment, Document form create+view, attachment > 1 MB triggers Git Data API path, item-list shows 📎 indicator.

## Out of scope (deferred to γ₂ or later)

- Trash view + restore/purge actions (γ₂)
- Field history view per item (γ₂)
- Device add/list/revoke UI (γ₂)
- Attachment caps configuration UI (γ₂; γ₁ reads them but doesn't edit them)
- Drag-drop file affordance
- Multi-file picker
- Attachment count badge on item-list rows
- Inline image preview pane in the standard attachments disclosure (only Document's primary attachment gets the preview-on-toggle)
- Orphan-blob garbage collection (γ₂'s caps UI may include a "purge orphans" action)
- Streaming/chunked uploads for very large files (>50 MB) — current design holds full plaintext + ciphertext in memory simultaneously; fine for typical use
- Resumable uploads after network failure — γ₁ retry is "user clicks again"

## Open questions deferred to plan

- **Document primary_attachment "change" UX:** clicking the `↑ change` button in edit mode replaces the primary. Does it (a) immediately delete the old blob, or (b) defer the delete until form save (matching standard attachment-removal lifecycle)? Plan ships (b) — consistent with the rest, lower risk if user "changes their mind" mid-edit.
- **Image preview thumbnail size:** 16×16 may render images as illegible blurs for portrait-orientation files. Plan ships 16×16 with `object-fit: cover` (centered crop); if user feedback wants 24×24 we adjust. The signature-block thumbs (48×60) use `object-fit: contain` to show the full image silhouette.
- **Per-vault size sum computation:** computing `sum(attachment.size)` across all manifest summaries on every upload is O(n_items × n_attachments_per_item). For vaults with >1000 items this could be ~10-100 ms. Plan: compute once at unlock, cache in popup state, increment on add / decrement on remove. Re-compute fresh from manifest on sync.
- **Filename collisions:** two attachments with the same filename on the same item — render both rows with the same name? Plan: yes; the underlying ID disambiguates them; the user sees two `screenshot.png` rows and can ✕ either one. Future polish: append `(2)` suffix to display name.
- **Download filename sanitization:** user-supplied filename goes directly to `chrome.downloads.download({filename})`. Chrome strips path separators automatically; Firefox does the same. Plan: trust the browser sanitization; no extra escaping in popup.
- **Error toast UX:** `humanizeError()` already exists in popup-side error handling per α design (spec referenced this). Plan: reuse `humanizeError(resp.error)` for upload/download failures.
