From e3a3327e65d14dae009e4363483b8405669a7ab5 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:09:16 -0700 Subject: [PATCH 01/11] docs(plans): add arc-paste removal design --- docs/plans/2026-06-07-remove-arc-paste.md | 164 ++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/plans/2026-06-07-remove-arc-paste.md diff --git a/docs/plans/2026-06-07-remove-arc-paste.md b/docs/plans/2026-06-07-remove-arc-paste.md new file mode 100644 index 0000000..9ddb31c --- /dev/null +++ b/docs/plans/2026-06-07-remove-arc-paste.md @@ -0,0 +1,164 @@ + +# Remove arc-paste / share stack — revert to legacy planner + +## Summary + +The arc-paste UI + standalone service + backend (the encrypted `arc share` +review surface) has not been well received. The previous setup — the legacy +planner (`arc plan` → `/planner/[planId]`) — was much better received and is +still fully intact in the tree. This plan removes the entire share/paste stack +via **surgical forward-removal**, restoring the legacy planner as the sole +design-review surface. + +## Key findings from exploration + +- **The two stacks coexist.** The legacy planner was never removed when + arc-paste landed (~v0.14.0, foundation commit `cfcbce9`). This is a "remove + the additive layer" problem, not a "restore deleted code" problem. +- **arc-paste is additive and well-isolated.** It never altered planner tables + or code; migration `017_shares.sql` only *creates* a `shares` table. It + couples to the rest of the system only at narrow seams. +- **The API `ServerConfig.DB *sql.DB` field exists solely** to feed paste route + registration — no other consumer — so it can be removed too. +- **A `git revert` range is impractical:** arc-paste's ~50 commits are + interleaved with unrelated work to keep (config-expand-and-ui #50, CI fixes, + workspace-paths). Forward-removal sidesteps this. + +## Decisions (locked) + +| Decision | Choice | Rationale | +|---|---|---| +| Mechanism | Surgical forward-removal | Easiest + best practice; never rewrite pushed/released history (PRs #38–#49, tags v0.14/v0.15) | +| Scope | Full stack | Half-removed features are dead weight; legacy planner fully covers the use case | +| DB/data | Add drop migration `018` | Forward-only migrations; never edit/delete a released migration (`017`) | +| Structure | Phased, build-green each step | Reviewable, bisectable; each commit compiles and tests pass | + +## Section 1 — Deletion inventory + +Whole directories / files removed: + +``` +arc-paste/ # standalone service: binary, Caddyfile, compose.yaml, Dockerfile, main.go +internal/paste/ # paste engine: crypto, anchor, handlers, storage, sqlite, types, cmd +internal/sharesconfig/ # HTTP shim +internal/client/shares.go (+_test) # CLI HTTP client methods for /shares ← found in grill +internal/api/paste_routes.go (+_test) # /api/paste/* mount +internal/api/shares.go # /shares handlers +internal/api/shares_import.go (+_test) # legacy shares.json import +internal/storage/shares.go (+_test) # shares store adapter +internal/storage/sqlite/db/queries/shares.sql # sqlc query source ← found in grill +internal/storage/sqlite/db/shares.sql.go # generated shares queries ← found in grill +internal/types/shares_test.go # share-type tests ← found in grill +cmd/arc/share.go (+_test) # arc share CLI +web/src/routes/share/ # /share/[id] UI + components +web/src/lib/paste/ # paste client/crypto/identity +docs/runbooks/paste-server.md # 100% paste feature ← found in grill +docs/runbooks/review_howto.md # 100% share Accept/Resolve/Reject (0 planner refs) ← found in grill +``` + +## Section 2 — Unwiring the seams (surgical edits, not deletions) + +| File | Edit | +|---|---| +| `internal/api/server.go` | Remove `DB *sql.DB` field (~L37-39), the `if cfg.DB != nil { registerPasteRoutes }` block (~L72-75), `RegisterShareRoutes` method (~L98-106), and its call (~L166-167) | +| `internal/server/server.go` | Remove `sharesconfig` import (~L15), `DB: store.DB()` (~L71), legacy shares.json import block (~L74-83) | +| `cmd/arc/main.go` | Remove `sharesconfig` import (~L20) + client factory wiring (~L265-268) | +| `internal/storage/sqlite/store.go` | Remove `pastesqlite` import (~L13) + paste migration apply (~L116-118) and its doc comment (~L126) | +| `Makefile` | Remove `build-paste` target (~L107-110) + any `webui` paste reference | +| `web/vite.config.ts` | Revert proxy target to `http://localhost:7432`, drop `ARC_PASTE_BACKEND` env override | +| `web/src/routes/+layout.svelte` | Remove `isShareRoute` derivation (~L27), the projects-fetch skip (~L31), and the `{#if isShareRoute}…{:else}…{/if}` branch (~L55+) — collapse to the normal app shell since `/share/` no longer exists | +| `web/src/routes/settings/+page.svelte` | Remove the `` block (author + server fields, ~L84-90). These bind to `working.share.*` and reference `errors['share.author'\|'share.server']`, both removed with `ShareConfig` | +| `internal/storage/storage.go` | Remove `ErrShareNotFound` (~L11-12) and the 5 interface methods: `UpsertShare`, `UpsertShares`, `GetShare`, `ListShares`, `DeleteShare` (~L94-99). *Found in grill — without this, deleting the adapter breaks the interface contract* | +| `internal/types/types.go` | Remove `ShareKind` + consts + `IsValid`, `AllShareKinds`, and the `Share` struct (~L348-368). *Found in grill* | +| `internal/api/workspace_paths_test.go` | Remove the 5 `mockWPStore` share stub methods (~L321-337) — they exist only to satisfy the `Storage` interface and reference `types.Share`. *Found in grill — kept test file* | +| `cmd/arc/config.go` | Remove share from the legacy-key map (~L30-31), valid-keys list (~L40-41), the `[share]` print block (~L143-145), and the get/set switch cases (~L327-330, L351-354). *Found in grill — `arc config` command* | +| `internal/storage/sqlite/db/schema.sql` | Remove the `shares` table DDL + `idx_shares_created_at` index (~L202-212) so sqlc regen is consistent. *Found in grill* | + +> **Discovered during grill:** the share/paste feature is referenced by two +> *kept* web files (the global layout and the #50 settings page). `make gen` +> handles the generated `web/src/lib/api/types.ts` (it currently carries the +> `/shares` operation types). Verify the settings page still loads/saves all +> remaining config fields after the Share section is removed. + +## Section 3 — Config schema change + +`internal/config/config.go` / `validate.go` / `migrate.go`: + +- Remove `ShareConfig` struct + the `Share ShareConfig` field + its default + (`https://arcplanner.sentiolabs.io`) +- Remove `share.server` URL validation +- Remove legacy `share_author` / `share_server` → `share.*` migration mapping + +## Section 4 — Database migration (forward-only) + +The paste engine maintains its **own** schema and migration tracker +(`paste_migrations` table) under `internal/paste/sqlite/migrations/001_init.sql`, +creating `paste_shares` and `paste_events`. Migration `018` (in arc's main +migration system) drops all of it in one shot: + +```sql +-- internal/storage/sqlite/migrations/018_drop_shares.sql +DROP TABLE IF EXISTS paste_events; +DROP TABLE IF EXISTS paste_shares; +DROP TABLE IF EXISTS paste_migrations; +DROP TABLE IF EXISTS shares; +``` + +This is a full teardown — stored shared-review content in `paste_shares` / +`paste_events` is intentionally deleted (the feature is being removed). +**`017_shares.sql` is left untouched** — already shipped in v0.14/v0.15; never +edit a released migration. + +## Section 5 — OpenAPI, generated artifacts, docs + +- `api/openapi.yaml`: remove `/shares` and `/shares/{shareId}` paths + their + schemas. +- **Generated artifacts — never hand-edited; regenerated by `make gen`** after + the `openapi.yaml` + `schema.sql` source edits land: + - `internal/api/openapi.gen.go` (oapi-codegen) + - `internal/storage/sqlite/db/models.go` (sqlc — drops the `Share` model) + - `web/src/lib/api/types.ts` (openapi-typescript — drops `/shares` ops) +- Runbooks (both 100% about the removed feature) are **deleted** in Section 1: + `docs/runbooks/paste-server.md`, `docs/runbooks/review_howto.md`. + +### Corrected scope (resolved in grill) + +- **Plugin skills are OUT OF SCOPE.** `claude-plugin/` does **not** exist in this + repo — the arc plugin/skills (`brainstorm`, `plan`) that reference `arc share` + live in a separate marketplace repo. They need a follow-up change *there* so + they stop pointing users at the removed surface, but that is not part of this + repo's removal. **Track as a separate follow-up issue.** +- **`CHANGELOG.md` is left untouched.** It is the historical record of shipped + releases (v0.14/v0.15 did ship `arc share`); rewriting it would falsify + history. The removal earns its own changelog entry via release-please when it + ships. +- **Historical design docs left untouched.** `docs/plans/2026-04-29-shared-review.md` + (and any other dated plan docs) are timestamped records, treated like git + history — not edited or deleted. + +## Section 6 — Phasing & verification + +Removal is **dependency-ordered**: consumers must be unwired before packages are +deleted. Phases 1 (web) and 2 (service) are independent and may run in parallel; +phases 3→4→5→6 are strictly sequential. + +1. Web removal +2. Service removal +3. Unwire Go seams (server.go, main.go, store.go, internal/server) +4. Delete Go packages (paste, sharesconfig, api shares/paste, storage shares) +5. Config (drop ShareConfig) +6. Migration `018` drop shares +7. OpenAPI regen + docs/skills cleanup + +**Verification gate after each phase:** `make build-quick` + `make test` stay +green. **Final gate:** full `make build` (frontend embeds) + a grep sweep +confirming zero `paste` / `share` / `sharesconfig` references remain outside git +history. + +## Out of scope / kept intact + +- Legacy planner: `cmd/arc/plan.go`, `internal/api/plans.go`, + `internal/storage/plans.go`, `web/src/routes/planner/`, migrations + `004/013/014`. +- Unrelated post-paste work: config-expand-and-ui (#50), CI fixes, + workspace-paths. From be17c32fc8ab0df6e993a5cde65d2c9044cf036d Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:12:54 -0700 Subject: [PATCH 02/11] refactor(web): remove share/paste UI stack Delete the /share/[id] SvelteKit route and web/src/lib/paste/ client lib. Strip isShareRoute special-casing from the global layout so it always renders the normal app shell. Remove the Share settings section from the settings page. Revert the vite proxy target to the hardcoded default port. --- web/src/lib/paste/anchor.test.ts | 33 -- web/src/lib/paste/anchor.ts | 79 --- web/src/lib/paste/client.test.ts | 34 -- web/src/lib/paste/client.ts | 68 --- web/src/lib/paste/crypto.test.ts | 42 -- web/src/lib/paste/crypto.ts | 70 --- web/src/lib/paste/crypto.xlang.test.ts | 32 - web/src/lib/paste/events.test.ts | 232 -------- web/src/lib/paste/events.ts | 84 --- web/src/lib/paste/identity.test.ts | 51 -- web/src/lib/paste/identity.ts | 25 - web/src/lib/paste/types.ts | 149 ----- web/src/routes/+layout.svelte | 70 +-- web/src/routes/settings/+page.svelte | 9 - web/src/routes/share/[id]/+page.svelte | 560 ------------------ web/src/routes/share/[id]/+page.ts | 7 - .../[id]/components/AnnotationCard.svelte | 493 --------------- .../[id]/components/AnnotationsPanel.svelte | 84 --- .../[id]/components/CommentPopover.svelte | 157 ----- .../[id]/components/FloatingToolbar.svelte | 324 ---------- .../[id]/components/NamePromptModal.svelte | 71 --- .../share/[id]/components/PlanRenderer.svelte | 211 ------- .../[id]/components/QuickLabelPicker.svelte | 89 --- .../components/inline-annotations.test.ts | 222 ------- .../[id]/components/inline-annotations.ts | 385 ------------ .../share/[id]/components/platform.test.ts | 91 --- .../routes/share/[id]/components/platform.ts | 34 -- .../share/[id]/components/positioning.test.ts | 38 -- .../share/[id]/components/positioning.ts | 33 -- web/vite.config.ts | 4 +- 30 files changed, 30 insertions(+), 3751 deletions(-) delete mode 100644 web/src/lib/paste/anchor.test.ts delete mode 100644 web/src/lib/paste/anchor.ts delete mode 100644 web/src/lib/paste/client.test.ts delete mode 100644 web/src/lib/paste/client.ts delete mode 100644 web/src/lib/paste/crypto.test.ts delete mode 100644 web/src/lib/paste/crypto.ts delete mode 100644 web/src/lib/paste/crypto.xlang.test.ts delete mode 100644 web/src/lib/paste/events.test.ts delete mode 100644 web/src/lib/paste/events.ts delete mode 100644 web/src/lib/paste/identity.test.ts delete mode 100644 web/src/lib/paste/identity.ts delete mode 100644 web/src/lib/paste/types.ts delete mode 100644 web/src/routes/share/[id]/+page.svelte delete mode 100644 web/src/routes/share/[id]/+page.ts delete mode 100644 web/src/routes/share/[id]/components/AnnotationCard.svelte delete mode 100644 web/src/routes/share/[id]/components/AnnotationsPanel.svelte delete mode 100644 web/src/routes/share/[id]/components/CommentPopover.svelte delete mode 100644 web/src/routes/share/[id]/components/FloatingToolbar.svelte delete mode 100644 web/src/routes/share/[id]/components/NamePromptModal.svelte delete mode 100644 web/src/routes/share/[id]/components/PlanRenderer.svelte delete mode 100644 web/src/routes/share/[id]/components/QuickLabelPicker.svelte delete mode 100644 web/src/routes/share/[id]/components/inline-annotations.test.ts delete mode 100644 web/src/routes/share/[id]/components/inline-annotations.ts delete mode 100644 web/src/routes/share/[id]/components/platform.test.ts delete mode 100644 web/src/routes/share/[id]/components/platform.ts delete mode 100644 web/src/routes/share/[id]/components/positioning.test.ts delete mode 100644 web/src/routes/share/[id]/components/positioning.ts diff --git a/web/src/lib/paste/anchor.test.ts b/web/src/lib/paste/anchor.test.ts deleted file mode 100644 index 407418e..0000000 --- a/web/src/lib/paste/anchor.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { resolveAnchor } from './anchor'; - -describe('resolveAnchor', () => { - const plan = '# Title\n\nFirst paragraph.\nSecond paragraph.\n## Sub\nThird.\n'; - - it('returns ok when quoted_text still at original lines', () => { - const r = resolveAnchor(plan, { line_start: 3, line_end: 3, quoted_text: 'First paragraph.' }); - expect(r.status).toBe('ok'); - }); - - it('returns drifted when found via heading slug', () => { - const edited = 'PRELUDE\n# Title\n\nMore content.\nFirst paragraph.\n## Sub\nThird.\n'; - const r = resolveAnchor(edited, { - line_start: 3, - line_end: 3, - quoted_text: 'First paragraph.', - heading_slug: 'title' - }); - expect(r.status).toBe('drifted'); - }); - - it('returns orphaned when text is gone', () => { - const edited = '# Title\n\nDifferent stuff.\n'; - const r = resolveAnchor(edited, { - line_start: 3, - line_end: 3, - quoted_text: 'First paragraph.', - heading_slug: 'title' - }); - expect(r.status).toBe('orphaned'); - }); -}); diff --git a/web/src/lib/paste/anchor.ts b/web/src/lib/paste/anchor.ts deleted file mode 100644 index 2ebcfbd..0000000 --- a/web/src/lib/paste/anchor.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Anchor } from './types'; - -export type AnchorResolution = { - line_start: number; - line_end: number; - char_start?: number; - char_end?: number; - status: 'ok' | 'drifted' | 'orphaned'; -}; - -export function resolveAnchor(plan: string, anchor: Anchor): AnchorResolution { - const lines = plan.split('\n'); - - if (anchor.line_start <= lines.length && anchor.line_end <= lines.length) { - const slice = lines.slice(anchor.line_start - 1, anchor.line_end).join('\n'); - if (slice.includes(anchor.quoted_text)) { - return { - line_start: anchor.line_start, - line_end: anchor.line_end, - char_start: anchor.char_start, - char_end: anchor.char_end, - status: 'ok' - }; - } - } - - if (anchor.heading_slug) { - const headingIdx = findHeadingIndex(lines, anchor.heading_slug); - if (headingIdx >= 0) { - const window = lines.slice(headingIdx, Math.min(headingIdx + 50, lines.length)).join('\n'); - const offset = window.indexOf(anchor.quoted_text); - if (offset >= 0) { - const lineNum = headingIdx + 1 + countNewlinesBefore(window, offset); - return { - line_start: lineNum, - line_end: lineNum + countNewlinesBefore(anchor.quoted_text, anchor.quoted_text.length), - status: 'drifted' - }; - } - } - } - - if (anchor.context_before && anchor.context_after) { - const needle = anchor.context_before + anchor.quoted_text + anchor.context_after; - const idx = plan.indexOf(needle); - if (idx >= 0) { - const lineNum = countNewlinesBefore(plan, idx + (anchor.context_before?.length ?? 0)) + 1; - return { - line_start: lineNum, - line_end: lineNum + countNewlinesBefore(anchor.quoted_text, anchor.quoted_text.length), - status: 'drifted' - }; - } - } - - return { line_start: anchor.line_start, line_end: anchor.line_end, status: 'orphaned' }; -} - -function findHeadingIndex(lines: string[], slug: string): number { - for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(/^#+\s+(.*)$/); - if (m && slugify(m[1]) === slug) return i; - } - return -1; -} - -export function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .trim() - .replace(/\s+/g, '-'); -} - -function countNewlinesBefore(s: string, idx: number): number { - let n = 0; - for (let i = 0; i < idx && i < s.length; i++) if (s[i] === '\n') n++; - return n; -} diff --git a/web/src/lib/paste/client.test.ts b/web/src/lib/paste/client.test.ts deleted file mode 100644 index 63e3e48..0000000 --- a/web/src/lib/paste/client.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { PasteClient } from './client'; - -describe('PasteClient', () => { - beforeEach(() => vi.restoreAllMocks()); - - it('POSTs base64-encoded blob+iv on create', async () => { - const fetchMock = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - new Response(JSON.stringify({ id: 'abc', edit_token: 'tok' }), { status: 201 }) - ); - const c = new PasteClient('http://x'); - const res = await c.create(new Uint8Array([1, 2]), new Uint8Array([3, 4])); - expect(res.id).toBe('abc'); - expect(fetchMock).toHaveBeenCalledWith( - 'http://x/api/paste', - expect.objectContaining({ method: 'POST' }) - ); - const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); - expect(body.schema_ver).toBe(1); - expect(body.plan_blob).toMatch(/^[A-Za-z0-9+/=]+$/); - }); - - it('sends Bearer token on update', async () => { - const fetchMock = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(null, { status: 204 })); - const c = new PasteClient('http://x'); - await c.updatePlan('abc', 'mytoken', new Uint8Array([1]), new Uint8Array([2])); - const opts = fetchMock.mock.calls[0][1] as RequestInit; - expect((opts.headers as Record).Authorization).toBe('Bearer mytoken'); - }); -}); diff --git a/web/src/lib/paste/client.ts b/web/src/lib/paste/client.ts deleted file mode 100644 index 381ebcb..0000000 --- a/web/src/lib/paste/client.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { CreatePasteResponse, GetPasteResponse, PasteEventResponse } from './types'; - -export class PasteClient { - constructor(private baseUrl: string) {} - - async create( - planBlob: Uint8Array, - planIv: Uint8Array, - schemaVer = 1 - ): Promise { - const res = await fetch(`${this.baseUrl}/api/paste`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - plan_blob: bytesToB64(planBlob), - plan_iv: bytesToB64(planIv), - schema_ver: schemaVer - }) - }); - if (!res.ok) throw new Error(`create paste failed: ${res.status}`); - return await res.json(); - } - - async get(id: string): Promise { - const res = await fetch(`${this.baseUrl}/api/paste/${id}`); - if (!res.ok) throw new Error(`get paste failed: ${res.status}`); - return await res.json(); - } - - async appendEvent(id: string, blob: Uint8Array, iv: Uint8Array): Promise<{ id: string }> { - const res = await fetch(`${this.baseUrl}/api/paste/${id}/blobs`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ blob: bytesToB64(blob), iv: bytesToB64(iv) }) - }); - if (!res.ok) throw new Error(`append event failed: ${res.status}`); - return await res.json(); - } - - async updatePlan( - id: string, - editToken: string, - planBlob: Uint8Array, - planIv: Uint8Array - ): Promise { - const res = await fetch(`${this.baseUrl}/api/paste/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${editToken}` }, - body: JSON.stringify({ plan_blob: bytesToB64(planBlob), plan_iv: bytesToB64(planIv) }) - }); - if (!res.ok) throw new Error(`update plan failed: ${res.status}`); - } -} - -function bytesToB64(b: Uint8Array): string { - return btoa(String.fromCharCode(...b)); -} - -export function b64ToBytes(s: string): Uint8Array { - const bin = atob(s); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -export function eventBytes(e: PasteEventResponse): { blob: Uint8Array; iv: Uint8Array } { - return { blob: b64ToBytes(e.blob), iv: b64ToBytes(e.iv) }; -} diff --git a/web/src/lib/paste/crypto.test.ts b/web/src/lib/paste/crypto.test.ts deleted file mode 100644 index 5bd1598..0000000 --- a/web/src/lib/paste/crypto.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - generateKey, - encryptJSON, - decryptJSON, - exportKey, - importKey, - base64UrlEncode, - base64UrlDecode -} from './crypto'; - -describe('paste crypto', () => { - it('round-trips JSON', async () => { - const key = await generateKey(); - const value = { hello: 'world', n: 42 }; - const { blob, iv } = await encryptJSON(value, key); - const decoded = await decryptJSON(blob, iv, key); - expect(decoded).toEqual(value); - }); - - it('fails to decrypt with wrong key', async () => { - const k1 = await generateKey(); - const k2 = await generateKey(); - const { blob, iv } = await encryptJSON('secret', k1); - await expect(decryptJSON(blob, iv, k2)).rejects.toBeDefined(); - }); - - it('exports and re-imports key losslessly', async () => { - const k = await generateKey(); - const exported = await exportKey(k); - const imported = await importKey(exported); - const { blob, iv } = await encryptJSON('hi', k); - const out = await decryptJSON(blob, iv, imported); - expect(out).toBe('hi'); - }); - - it('base64url round-trips arbitrary bytes', () => { - const bytes = new Uint8Array([0, 255, 1, 254, 100]); - const decoded = base64UrlDecode(base64UrlEncode(bytes)); - expect([...decoded]).toEqual([...bytes]); - }); -}); diff --git a/web/src/lib/paste/crypto.ts b/web/src/lib/paste/crypto.ts deleted file mode 100644 index 487f1ce..0000000 --- a/web/src/lib/paste/crypto.ts +++ /dev/null @@ -1,70 +0,0 @@ -const ALGO = 'AES-GCM'; -const KEY_LEN_BITS = 256; -const IV_LEN_BYTES = 12; - -/** - * Web Crypto APIs accept `BufferSource = ArrayBuffer | ArrayBufferView`, but - * TypeScript's strict types reject `Uint8Array` because the - * `ArrayBufferLike` union includes `SharedArrayBuffer`, which Web Crypto - * forbids. Copy bytes into a fresh `ArrayBuffer` to satisfy both the runtime - * contract and the type checker. - */ -function asArrayBuffer(view: Uint8Array): ArrayBuffer { - const out = new ArrayBuffer(view.byteLength); - new Uint8Array(out).set(view); - return out; -} - -export async function generateKey(): Promise { - return crypto.subtle.generateKey({ name: ALGO, length: KEY_LEN_BITS }, true, [ - 'encrypt', - 'decrypt' - ]); -} - -export async function exportKey(key: CryptoKey): Promise { - const raw = await crypto.subtle.exportKey('raw', key); - return base64UrlEncode(new Uint8Array(raw)); -} - -export async function importKey(b64url: string): Promise { - const raw = asArrayBuffer(base64UrlDecode(b64url)); - return crypto.subtle.importKey('raw', raw, { name: ALGO }, true, ['encrypt', 'decrypt']); -} - -export async function encryptJSON( - value: T, - key: CryptoKey -): Promise<{ blob: Uint8Array; iv: Uint8Array }> { - const iv = crypto.getRandomValues(new Uint8Array(IV_LEN_BYTES)); - const plaintext = new TextEncoder().encode(JSON.stringify(value)); - const ct = await crypto.subtle.encrypt( - { name: ALGO, iv: asArrayBuffer(iv) }, - key, - asArrayBuffer(plaintext) - ); - return { blob: new Uint8Array(ct), iv }; -} - -export async function decryptJSON(blob: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise { - const plain = await crypto.subtle.decrypt( - { name: ALGO, iv: asArrayBuffer(iv) }, - key, - asArrayBuffer(blob) - ); - return JSON.parse(new TextDecoder().decode(plain)) as T; -} - -export function base64UrlEncode(bytes: Uint8Array): string { - const s = btoa(String.fromCharCode(...bytes)); - return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -export function base64UrlDecode(s: string): Uint8Array { - const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); - const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad; - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} diff --git a/web/src/lib/paste/crypto.xlang.test.ts b/web/src/lib/paste/crypto.xlang.test.ts deleted file mode 100644 index fae9c7c..0000000 --- a/web/src/lib/paste/crypto.xlang.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { importKey, decryptJSON } from './crypto'; - -const FIXTURES_PATH = join(__dirname, '../../../../internal/paste/testdata/xlang_fixtures.json'); -const fixtures: Array<{ - name: string; - key_b64url: string; - plaintext: unknown; - ciphertext_b64: string; - iv_b64: string; -}> = JSON.parse(readFileSync(FIXTURES_PATH, 'utf8')); - -describe('crypto xlang', () => { - for (const f of fixtures) { - it(`decrypts Go-produced fixture: ${f.name}`, async () => { - const key = await importKey(f.key_b64url); - const blob = stdB64Decode(f.ciphertext_b64); - const iv = stdB64Decode(f.iv_b64); - const got = await decryptJSON(blob, iv, key); - expect(got).toEqual(f.plaintext); - }); - } -}); - -function stdB64Decode(s: string): Uint8Array { - const bin = atob(s); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} diff --git a/web/src/lib/paste/events.test.ts b/web/src/lib/paste/events.test.ts deleted file mode 100644 index 7bf5217..0000000 --- a/web/src/lib/paste/events.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { replayEvents, acceptedOnly } from './events'; -import type { CommentEvent, EditEvent, ResolutionEvent, RetractionEvent } from './types'; - -const c: CommentEvent = { - kind: 'comment', - id: 'c1', - author_name: 'Alice', - comment_type: 'comment', - body: 'looks good', - anchor: { line_start: 1, line_end: 1, quoted_text: 'x' }, - created_at: '2026-04-29T00:00:00Z' -}; -const accept: ResolutionEvent = { - kind: 'resolution', - id: 'r1', - comment_id: 'c1', - status: 'accepted', - author_name: 'Ben', - created_at: '2026-04-29T00:01:00Z' -}; - -describe('replayEvents', () => { - it('marks comment accepted when resolver is plan author', () => { - const states = replayEvents('Ben', [c, accept]); - expect(states.get('c1')?.status).toBe('accepted'); - }); - - it('ignores resolution from non-author', () => { - const states = replayEvents('Ben', [c, { ...accept, author_name: 'Mallory' }]); - expect(states.get('c1')?.status).toBe('open'); - }); - - it('acceptedOnly filters non-accepted', () => { - const states = replayEvents('Ben', [c, accept]); - expect(acceptedOnly(states).length).toBe(1); - }); - - it('replays in created_at order', () => { - const reject: ResolutionEvent = { - ...accept, - id: 'r2', - status: 'rejected', - created_at: '2026-04-29T00:02:00Z' - }; - const states = replayEvents('Ben', [c, accept, reject]); - expect(states.get('c1')?.status).toBe('rejected'); - }); - - describe('edit events', () => { - const baseEdit: EditEvent = { - kind: 'edit', - id: 'e1', - comment_id: 'c1', - author_name: 'Alice', - body: 'expanded reasoning: the issue is X because Y', - created_at: '2026-04-29T00:05:00Z' - }; - - it("applies edits to the original author's comment", () => { - const states = replayEvents('Ben', [c, baseEdit]); - const s = states.get('c1'); - expect(s?.event.body).toBe('expanded reasoning: the issue is X because Y'); - expect(s?.editedAt).toBe('2026-04-29T00:05:00Z'); - }); - - it('discards edits forged by someone else', () => { - const forged: EditEvent = { ...baseEdit, author_name: 'Mallory' }; - const states = replayEvents('Ben', [c, forged]); - const s = states.get('c1'); - expect(s?.event.body).toBe('looks good'); // unchanged - expect(s?.editedAt).toBeUndefined(); - }); - - it('applies multiple edits in chronological order', () => { - const second: EditEvent = { - ...baseEdit, - id: 'e2', - body: 'final wording', - created_at: '2026-04-29T00:10:00Z' - }; - // Pass them out of order to confirm sorting is what matters. - const states = replayEvents('Ben', [c, second, baseEdit]); - expect(states.get('c1')?.event.body).toBe('final wording'); - expect(states.get('c1')?.editedAt).toBe('2026-04-29T00:10:00Z'); - }); - - it('only updates fields explicitly present in the edit', () => { - const withSuggestion: CommentEvent = { - ...c, - suggested_text: 'original replacement' - }; - // Edit changes body but not suggested_text — original suggestion must persist. - const bodyOnly: EditEvent = { - ...baseEdit, - body: 'new body', - suggested_text: undefined - }; - const states = replayEvents('Ben', [withSuggestion, bodyOnly]); - expect(states.get('c1')?.event.body).toBe('new body'); - expect(states.get('c1')?.event.suggested_text).toBe('original replacement'); - }); - - it('treats empty string as an explicit clear', () => { - const withSuggestion: CommentEvent = { - ...c, - suggested_text: 'replacement' - }; - const clearSuggestion: EditEvent = { - ...baseEdit, - body: undefined, - suggested_text: '' - }; - const states = replayEvents('Ben', [withSuggestion, clearSuggestion]); - expect(states.get('c1')?.event.suggested_text).toBe(''); - }); - - it('drops edits for nonexistent comments without throwing', () => { - const orphan: EditEvent = { ...baseEdit, comment_id: 'does-not-exist' }; - const states = replayEvents('Ben', [c, orphan]); - expect(states.size).toBe(1); // c1 still present, orphan ignored - }); - - it('preserves resolution + edit interleaving', () => { - // Comment → accepted → edited. Status must remain 'accepted' but - // the body must reflect the edit (the author refining their note - // after acceptance). - const states = replayEvents('Ben', [c, accept, baseEdit]); - const s = states.get('c1'); - expect(s?.status).toBe('accepted'); - expect(s?.event.body).toBe('expanded reasoning: the issue is X because Y'); - }); - - describe('plan author edits reviewer comments', () => { - it('lets the plan author refine a reviewer comment', () => { - // Steve leaves a thin "expand this more"; Ben (plan author) - // rewrites the body. comment.author_name stays "Alice" - // (the original reviewer) — only the body changes. - const banEdit = { - ...baseEdit, - author_name: 'Ben', - body: 'success criteria for "validated" should be enumerated' - }; - const states = replayEvents('Ben', [c, banEdit]); - const s = states.get('c1'); - expect(s?.event.body).toBe( - 'success criteria for "validated" should be enumerated' - ); - // Original author attribution preserved. - expect(s?.event.author_name).toBe('Alice'); - expect(s?.editedAt).toBe(banEdit.created_at); - }); - - it('still drops edits from third parties', () => { - const malloryEdit = { - ...baseEdit, - author_name: 'Mallory', - body: 'rewrite by random visitor' - }; - const states = replayEvents('Ben', [c, malloryEdit]); - expect(states.get('c1')?.event.body).toBe('looks good'); - }); - - it('does not grant plan-author rights when planAuthor is empty', () => { - // Edge case: if the plan was created without an author name, - // an edit event with empty author_name would otherwise match - // the empty plan_author. This must not authorize the edit. - const empty = { ...baseEdit, author_name: '', body: 'should not apply' }; - const states = replayEvents(undefined, [c, empty]); - expect(states.get('c1')?.event.body).toBe('looks good'); - }); - - it('applies original-author then plan-author edits chronologically', () => { - // Alice refines her own wording, then Ben (author) refines - // it further. Last write wins. - const aliceFirst = { - ...baseEdit, - id: 'e1', - author_name: 'Alice', - body: 'alice version', - created_at: '2026-04-29T00:05:00Z' - }; - const benSecond = { - ...baseEdit, - id: 'e2', - author_name: 'Ben', - body: 'ben final', - created_at: '2026-04-29T00:10:00Z' - }; - const states = replayEvents('Ben', [c, aliceFirst, benSecond]); - expect(states.get('c1')?.event.body).toBe('ben final'); - }); - }); - }); - - describe('retraction events', () => { - const retract: RetractionEvent = { - kind: 'retraction', - id: 'x1', - comment_id: 'c1', - author_name: 'Alice', - created_at: '2026-04-29T00:08:00Z' - }; - - it('marks comment retracted when original author retracts', () => { - const states = replayEvents('Ben', [c, retract]); - expect(states.get('c1')?.status).toBe('retracted'); - }); - - it('ignores retraction by someone other than the original commenter', () => { - // Plan author Ben tries to retract Alice's comment — must be silently dropped. - const states = replayEvents('Ben', [c, { ...retract, author_name: 'Ben' }]); - expect(states.get('c1')?.status).toBe('open'); - }); - - it('ignores forged retraction by a third party', () => { - const states = replayEvents('Ben', [c, { ...retract, author_name: 'Mallory' }]); - expect(states.get('c1')?.status).toBe('open'); - }); - - it('ignores retraction targeting a non-existent comment', () => { - const states = replayEvents('Ben', [c, { ...retract, comment_id: 'nope' }]); - expect(states.get('c1')?.status).toBe('open'); - }); - - it('retraction overrides a prior accept (chronological replay)', () => { - // Edge case: author accepts, reviewer then retracts. Last write wins. - const states = replayEvents('Ben', [c, accept, retract]); - expect(states.get('c1')?.status).toBe('retracted'); - }); - }); -}); diff --git a/web/src/lib/paste/events.ts b/web/src/lib/paste/events.ts deleted file mode 100644 index 4a0df3f..0000000 --- a/web/src/lib/paste/events.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { CommentEvent, EventPlaintext, ResolutionStatus } from './types'; - -/** - * 'retracted' is added to the status union for original-commenter retractions. - * It is intentionally distinct from author-driven 'rejected': retraction means - * "I take this back, never mind"; reject means "the author considered this - * and explicitly disagreed." Different semantics, different consumers. - */ -export type CommentStatus = 'open' | ResolutionStatus | 'retracted'; - -export type CommentState = { - event: CommentEvent; - status: CommentStatus; - reply?: string; - replyAt?: string; - /** - * Timestamp of the most recent successfully-applied EditEvent for this - * comment. Undefined if the comment has never been edited. The UI uses - * this to render an "edited" indicator. - */ - editedAt?: string; -}; - -export function replayEvents( - planAuthor: string | undefined, - events: EventPlaintext[] -): Map { - const sorted = [...events].sort((a, b) => { - const c = a.created_at.localeCompare(b.created_at); - return c !== 0 ? c : a.id.localeCompare(b.id); - }); - const states = new Map(); - for (const e of sorted) { - if (e.kind === 'comment') { - states.set(e.id, { event: e, status: 'open' }); - } else if (e.kind === 'resolution') { - const target = states.get(e.comment_id); - if (!target) continue; - if (planAuthor && e.author_name !== planAuthor) continue; - target.status = e.status; - target.reply = e.reply; - target.replyAt = e.created_at; - } else if (e.kind === 'edit') { - const target = states.get(e.comment_id); - if (!target) continue; - // Edits are accepted from two roles: - // 1. The original author of the comment (refining their own wording). - // 2. The plan author (sharpening reviewer feedback into something - // LLM-consumable without waiting on the reviewer). - // The displayed `comment.author_name` does NOT change either way — - // the edit event in the log records who actually edited. - // - // `planAuthor` must be non-empty before granting plan-owner edit - // rights; otherwise empty strings would all match each other. - const isOriginalAuthor = e.author_name === target.event.author_name; - const isPlanAuthor = !!planAuthor && e.author_name === planAuthor; - if (!isOriginalAuthor && !isPlanAuthor) continue; - // Build a new event object with the supplied fields applied. We - // replace `target.event` rather than mutate so consumers that hold - // a stale reference don't see partial state during the merge. - target.event = { - ...target.event, - body: e.body !== undefined ? e.body : target.event.body, - suggested_text: - e.suggested_text !== undefined ? e.suggested_text : target.event.suggested_text, - comment_type: e.comment_type !== undefined ? e.comment_type : target.event.comment_type - }; - target.editedAt = e.created_at; - } else if (e.kind === 'retraction') { - const target = states.get(e.comment_id); - if (!target) continue; - // Only the original commenter can retract. Plan author canNOT — - // they have Reject (with reply) for that purpose, which preserves - // rationale in the audit trail. - if (e.author_name !== target.event.author_name) continue; - target.status = 'retracted'; - } - } - return states; -} - -export function acceptedOnly(states: Map): CommentState[] { - return [...states.values()].filter((s) => s.status === 'accepted'); -} diff --git a/web/src/lib/paste/identity.test.ts b/web/src/lib/paste/identity.test.ts deleted file mode 100644 index de1cbc1..0000000 --- a/web/src/lib/paste/identity.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, beforeEach } from 'vitest'; -import { getReviewerName, setReviewerName, clearReviewerName, parseShareFragment } from './identity'; - -describe('identity', () => { - beforeEach(() => localStorage.clear()); - - it('round-trips a name', () => { - setReviewerName('Alice'); - expect(getReviewerName()).toBe('Alice'); - }); - - it('clear removes the name', () => { - setReviewerName('Bob'); - clearReviewerName(); - expect(getReviewerName()).toBeNull(); - }); - - it('trims whitespace on set', () => { - setReviewerName(' Carol '); - expect(getReviewerName()).toBe('Carol'); - }); -}); - -describe('parseShareFragment', () => { - it('parses k only', () => { - expect(parseShareFragment('#k=abc')).toEqual({ k: 'abc', t: null }); - }); - - it('parses k and t', () => { - expect(parseShareFragment('#k=abc&t=xyz')).toEqual({ k: 'abc', t: 'xyz' }); - }); - - it('order-independent', () => { - expect(parseShareFragment('#t=xyz&k=abc')).toEqual({ k: 'abc', t: 'xyz' }); - }); - - it('handles missing leading #', () => { - expect(parseShareFragment('k=abc&t=xyz')).toEqual({ k: 'abc', t: 'xyz' }); - }); - - it('returns null for missing keys', () => { - expect(parseShareFragment('')).toEqual({ k: null, t: null }); - expect(parseShareFragment('#')).toEqual({ k: null, t: null }); - expect(parseShareFragment('#other=foo')).toEqual({ k: null, t: null }); - }); - - it('treats empty values as null', () => { - expect(parseShareFragment('#k=&t=')).toEqual({ k: null, t: null }); - }); -}); diff --git a/web/src/lib/paste/identity.ts b/web/src/lib/paste/identity.ts deleted file mode 100644 index 1796b59..0000000 --- a/web/src/lib/paste/identity.ts +++ /dev/null @@ -1,25 +0,0 @@ -const KEY = 'arc.reviewer.name'; - -export function getReviewerName(): string | null { - if (typeof localStorage === 'undefined') return null; - return localStorage.getItem(KEY); -} - -export function setReviewerName(name: string): void { - if (typeof localStorage === 'undefined') return; - localStorage.setItem(KEY, name.trim()); -} - -export function clearReviewerName(): void { - if (typeof localStorage === 'undefined') return; - localStorage.removeItem(KEY); -} - -export function parseShareFragment(hash: string): { k: string | null; t: string | null } { - const raw = hash.startsWith('#') ? hash.slice(1) : hash; - const params = new URLSearchParams(raw); - return { - k: params.get('k') || null, - t: params.get('t') || null - }; -} diff --git a/web/src/lib/paste/types.ts b/web/src/lib/paste/types.ts deleted file mode 100644 index 88ccea1..0000000 --- a/web/src/lib/paste/types.ts +++ /dev/null @@ -1,149 +0,0 @@ -export type PasteShareResponse = { - id: string; - plan_blob: string; - plan_iv: string; - schema_ver: number; - created_at: string; - updated_at: string; - expires_at?: string; -}; - -export type PasteEventResponse = { - id: string; - share_id: string; - blob: string; - iv: string; - created_at: string; -}; - -export type GetPasteResponse = PasteShareResponse & { events: PasteEventResponse[] }; - -export type CreatePasteRequest = { - plan_blob: string; - plan_iv: string; - schema_ver: number; - expires_at?: string; -}; - -export type CreatePasteResponse = { id: string; edit_token: string }; - -export type AppendEventRequest = { blob: string; iv: string }; - -export type PlanPlaintext = { - version: 1; - markdown: string; - title?: string; - author_name?: string; - created_at: string; -}; - -export type CommentType = 'comment' | 'praise' | 'issue' | 'suggestion' | 'question' | 'nit'; -export type Severity = 'important' | 'nit'; -export type ResolutionStatus = 'accepted' | 'rejected' | 'resolved' | 'reopened'; - -export type Anchor = { - line_start: number; - line_end: number; - char_start?: number; - char_end?: number; - quoted_text: string; - context_before?: string; - context_after?: string; - heading_slug?: string; -}; - -/** - * The reviewer's intent. Plannotator parity: COMMENT vs DELETION are the - * primary actions. `comment_type` (praise/issue/etc.) is a secondary label. - * Body is required for action='comment' but optional for 'delete' — the - * strikethrough IS the action. - */ -export type AnnotationAction = 'comment' | 'delete'; - -export type CommentEvent = { - kind: 'comment'; - id: string; - author_name: string; - /** Primary intent. Defaults to 'comment' for back-compat with v1 events. */ - action?: AnnotationAction; - comment_type: CommentType; - severity?: Severity; - /** Required for action='comment'; may be empty for action='delete'. */ - body: string; - suggested_text?: string; - parent_id?: string; - anchor: Anchor; - created_at: string; -}; - -export type ResolutionEvent = { - kind: 'resolution'; - id: string; - comment_id: string; - status: ResolutionStatus; - reply?: string; - author_name: string; - created_at: string; -}; - -/** - * A reviewer revising their own annotation. Append-only: rather than mutating - * the original CommentEvent blob, we emit a new EditEvent that references the - * target comment by id and supplies new field values. Replay merges these in - * chronological order, gated on `author_name === target.author_name` so only - * the original author's edits take effect. - * - * Only `body`, `suggested_text`, and `comment_type` can be edited. Changing - * `action` (comment vs delete) or `anchor` would change the meaning of the - * annotation — the reviewer should delete and re-create instead. - * - * Field semantics: - * - `body` undefined → keep current body - * - `body` "" → clear body (rare but valid) - * - `body` non-empty → replace body - * Same rules for `suggested_text` and `comment_type`. - */ -export type EditEvent = { - kind: 'edit'; - id: string; - comment_id: string; - author_name: string; - body?: string; - suggested_text?: string; - comment_type?: CommentType; - created_at: string; -}; - -export type PlanEditEvent = { - kind: 'plan_edit'; - id: string; - edit_summary?: string; - created_at: string; -}; - -/** - * The original commenter retracting their own annotation. Replay marks the - * target comment with status='retracted'; UI hides retracted comments and - * their inline marks. The encrypted event stays in the log so the action is - * auditable, but `arc share comments` filters retracted entries out of the - * default output (LLM consumers shouldn't act on retracted material). - * - * Authorization is replay-time: only an event whose `author_name` matches - * the target comment's `author_name` takes effect. The plan author canNOT - * retract someone else's comment — they must use Reject (with a reply that - * preserves rationale in the audit trail). - */ -export type RetractionEvent = { - kind: 'retraction'; - id: string; - comment_id: string; - author_name: string; - created_at: string; -}; - -export type EventPlaintext = - | CommentEvent - | ResolutionEvent - | EditEvent - | RetractionEvent - | PlanEditEvent; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 797c439..b08ebb7 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -19,16 +19,9 @@ const currentProjectId = $derived($page.params.projectId); const currentProject = $derived($projectsStore.find((p) => p.id === currentProjectId)); - // /share/[id] is a focused review surface that opts out of arc's app shell. - // On a public arc-paste deploy, /api/v1/projects doesn't exist, so loading - // projects there would put the entire SPA into an error state and the - // share page would never render. Even on local arc-server, hiding arc's - // chrome keeps the share URL behaving consistently across hosts. - const isShareRoute = $derived($page.url.pathname.startsWith('/share/')); - - // Load projects on mount (skipped on /share routes) + // Load projects on mount $effect(() => { - if (!isShareRoute) loadProjects(); + loadProjects(); }); async function loadProjects() { @@ -52,38 +45,33 @@ -{#if isShareRoute} - - {@render children()} -{:else} -
- +
+ -
- {#if $loadingStore} -
-
Loading...
-
- {:else if $errorStore} -
-
-
- - - -
-

Connection Error

-

{$errorStore}

- +
+ {#if $loadingStore} +
+
Loading...
+
+ {:else if $errorStore} +
+
+
+ + +
+

Connection Error

+

{$errorStore}

+
- {:else} - {@render children()} - {/if} -
-
-{/if} +
+ {:else} + {@render children()} + {/if} +
+
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index f816678..6e765e3 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -81,15 +81,6 @@ - - - - - - - - - - import { onMount } from 'svelte'; - import { PasteClient, b64ToBytes, eventBytes } from '$lib/paste/client'; - import { importKey, encryptJSON, decryptJSON } from '$lib/paste/crypto'; - import { replayEvents, type CommentState } from '$lib/paste/events'; - import { getReviewerName, parseShareFragment, setReviewerName } from '$lib/paste/identity'; - import type { - PlanPlaintext, - EventPlaintext, - CommentEvent, - CommentType, - EditEvent, - ResolutionEvent, - ResolutionStatus, - RetractionEvent, - Anchor - } from '$lib/paste/types'; - import PlanRenderer from './components/PlanRenderer.svelte'; - import FloatingToolbar, { type ToolbarAction } from './components/FloatingToolbar.svelte'; - import CommentPopover, { type PopoverMode } from './components/CommentPopover.svelte'; - import QuickLabelPicker from './components/QuickLabelPicker.svelte'; - import AnnotationsPanel from './components/AnnotationsPanel.svelte'; - import NamePromptModal from './components/NamePromptModal.svelte'; - import type { InlineMark } from './components/inline-annotations.ts'; - - const { data }: { data: { id: string } } = $props(); - - // --- Decrypted state --- - let plan = $state(null); - let comments = $state(new Map()); - let key = $state(null); - let loadError = $state(null); - - // --- Reviewer identity --- - let reviewerName = $state(null); - let authorToken = $state(null); - let showNamePrompt = $state(false); - - // --- Share-link copy (author-only) --- - let copiedShareLink = $state(false); - let copyResetTimer: ReturnType | null = null; - - // --- UI state for selection-driven actions --- - type SelectionInfo = { - lineStart: number; - lineEnd: number; - quotedText: string; - headingSlug?: string; - contextBefore?: string; - contextAfter?: string; - rect: DOMRect; - }; - let activeSelection = $state(null); - let popoverMode = $state(null); - let showQuickLabel = $state(false); - let activeMarkId = $state(undefined); - // When a quick-label without a preset body routes the user into the - // comment popover (e.g. Nit / Issue / Question), we hold their picked - // taxonomy here so the eventual save uses it instead of falling back - // to the generic 'comment' type. - let pendingCommentType = $state(null); - - let client: PasteClient | undefined; - - const isAuthor = $derived(authorToken !== null); - - const orderedStates = $derived.by(() => { - return [...comments.values()] - .filter((s) => s.status !== 'retracted') - .sort((a, b) => b.event.created_at.localeCompare(a.event.created_at)); - }); - - const marks = $derived.by((): InlineMark[] => { - const out: InlineMark[] = []; - for (const state of comments.values()) { - if ( - state.status === 'resolved' || - state.status === 'rejected' || - state.status === 'retracted' - ) - continue; - out.push({ - id: state.event.id, - kind: state.event.action === 'delete' ? 'delete' : 'comment', - lineStart: state.event.anchor.line_start, - lineEnd: state.event.anchor.line_end, - quotedText: state.event.anchor.quoted_text - }); - } - return out; - }); - - onMount(async () => { - reviewerName = getReviewerName(); - client = new PasteClient(window.location.origin); - - try { - const { k, t } = parseShareFragment(window.location.hash); - if (!k) { - loadError = 'Missing #k= in URL — share link is incomplete.'; - return; - } - key = await importKey(k); - authorToken = t; - - const resp = await client.get(data.id); - plan = await decryptJSON( - b64ToBytes(resp.plan_blob), - b64ToBytes(resp.plan_iv), - key - ); - - // Author URL flow: token + plan author name → auto-populate reviewer identity. - // If the share was created without --author, fall through to the - // reviewerName already loaded from localStorage at the top of onMount — - // the lazy NamePromptModal will fire on first action. isAuthor still - // stays true via authorToken in that case. - if (authorToken && plan?.author_name) { - reviewerName = plan.author_name; - setReviewerName(plan.author_name); - } - - const events: EventPlaintext[] = []; - for (const ev of resp.events) { - const { blob, iv } = eventBytes(ev); - try { - events.push(await decryptJSON(blob, iv, key)); - } catch { - // skip undecryptable events - } - } - comments = replayEvents(plan?.author_name, events); - } catch (err) { - loadError = (err as Error)?.message ?? 'Failed to load share'; - } - }); - - function handleNameSaved(name: string) { - reviewerName = name; - showNamePrompt = false; - } - - function clearSelection() { - activeSelection = null; - popoverMode = null; - showQuickLabel = false; - pendingCommentType = null; - const sel = window.getSelection(); - sel?.removeAllRanges(); - } - - // Build the bare share URL (no &t=) from the current location and copy it. - // Author URL fragment carries both k and t; reviewers must only ever see k. - async function copyShareLink() { - const { k } = parseShareFragment(window.location.hash); - if (!k) return; - const url = `${window.location.origin}${window.location.pathname}#k=${k}`; - try { - await navigator.clipboard.writeText(url); - } catch { - return; - } - copiedShareLink = true; - if (copyResetTimer) clearTimeout(copyResetTimer); - copyResetTimer = setTimeout(() => { - copiedShareLink = false; - copyResetTimer = null; - }, 1500); - } - - async function postEvent(event: EventPlaintext) { - if (!key || !client) return; - const { blob, iv } = await encryptJSON(event, key); - await client.appendEvent(data.id, blob, iv); - } - - async function postAuthorEvent(event: EventPlaintext) { - // Phase B: identical to postEvent. Phase C will add an auth header here - // and route the call through a server endpoint that verifies authorToken. - return postEvent(event); - } - - function buildAnchor(sel: SelectionInfo): Anchor { - return { - line_start: sel.lineStart, - line_end: sel.lineEnd, - quoted_text: sel.quotedText, - heading_slug: sel.headingSlug, - context_before: sel.contextBefore, - context_after: sel.contextAfter - }; - } - - async function createComment(opts: { - body: string; - comment_type?: CommentType; - action?: 'comment' | 'delete'; - suggested_text?: string; - anchor: Anchor; - }) { - if (!reviewerName) return; - const event: CommentEvent = { - kind: 'comment', - id: `c-${crypto.randomUUID()}`, - author_name: reviewerName, - action: opts.action ?? 'comment', - comment_type: opts.comment_type ?? 'comment', - body: opts.body, - suggested_text: opts.suggested_text, - anchor: opts.anchor, - created_at: new Date().toISOString() - }; - await postEvent(event); - const next = new Map(comments); - next.set(event.id, { event, status: 'open' }); - comments = next; - } - - // Name capture moved into FloatingToolbar.svelte: when reviewerName is - // null, the toolbar renders an inline name field and gates its action - // icons on it. By the time we receive an action here, we know the name - // is set — so these handlers no longer need ensureName(). - async function handleToolbarAction(action: ToolbarAction) { - if (!activeSelection) return; - const sel = activeSelection; - - switch (action) { - case 'praise': - await createComment({ - body: 'Looks good', - comment_type: 'praise', - action: 'comment', - anchor: buildAnchor(sel) - }); - clearSelection(); - return; - case 'comment': - popoverMode = 'comment'; - return; - case 'delete': - await createComment({ - body: '', - comment_type: 'comment', - action: 'delete', - anchor: buildAnchor(sel) - }); - clearSelection(); - return; - case 'suggest': - popoverMode = 'suggest'; - return; - case 'quick-label': - showQuickLabel = true; - return; - } - } - - function handleSetName(name: string) { - reviewerName = name; - setReviewerName(name); - } - - async function handlePopoverSave(body: string, suggestedText?: string) { - if (!activeSelection) return; - await createComment({ - body, - // pendingCommentType is an explicit user choice from QuickLabelPicker, - // so it wins over the suggestedText inference. - comment_type: pendingCommentType ?? (suggestedText ? 'suggestion' : 'comment'), - action: 'comment', - suggested_text: suggestedText, - anchor: buildAnchor(activeSelection) - }); - clearSelection(); - } - - async function handleQuickLabelPick(label: CommentType, presetBody: string) { - if (!activeSelection) return; - const sel = activeSelection; - showQuickLabel = false; - if (presetBody) { - await createComment({ - body: presetBody, - comment_type: label, - action: 'comment', - anchor: buildAnchor(sel) - }); - clearSelection(); - } else { - // No preset — open the comment popover so the user can write a body. - // Remember the picked label so handlePopoverSave can preserve it. - pendingCommentType = label; - popoverMode = 'comment'; - } - } - - async function handleEdit( - commentId: string, - body: string, - suggestedText: string | undefined - ) { - if (!reviewerName) return; - const target = comments.get(commentId); - if (!target) return; - // Authorization mirror of replayEvents: - // - Original commenter can edit their own comment. - // - Plan author can edit any comment (sharpening thin feedback). - // Failing fast here avoids posting events the replay would discard. - const isMyComment = target.event.author_name === reviewerName; - if (!isMyComment && !isAuthor) return; - - const event: EditEvent = { - kind: 'edit', - id: `e-${crypto.randomUUID()}`, - comment_id: commentId, - author_name: reviewerName, - body, - suggested_text: suggestedText, - created_at: new Date().toISOString() - }; - if (isAuthor && !isMyComment) { - await postAuthorEvent(event); - } else { - await postEvent(event); - } - - // Apply locally so the card updates without a round-trip refetch. - const next = new Map(comments); - next.set(commentId, { - ...target, - event: { - ...target.event, - body, - suggested_text: suggestedText !== undefined ? suggestedText : target.event.suggested_text - }, - editedAt: event.created_at - }); - comments = next; - } - - async function handleResolve(commentId: string, status: ResolutionStatus, reply?: string) { - if (!reviewerName) return; - const event: ResolutionEvent = { - kind: 'resolution', - id: `r-${crypto.randomUUID()}`, - comment_id: commentId, - status, - reply, - author_name: reviewerName, - created_at: new Date().toISOString() - }; - await postAuthorEvent(event); - const next = new Map(comments); - const target = next.get(commentId); - if (target && isAuthor) { - next.set(commentId, { ...target, status, reply, replyAt: event.created_at }); - } - comments = next; - } - - // The original commenter retracting their own annotation. Goes through the - // regular postEvent (not postAuthorEvent) — retraction is a reviewer-side - // authority, not an author privilege, so a future Phase C server-enforced - // auth would NOT gate retraction behind the author token. - async function handleRetract(commentId: string) { - if (!reviewerName) return; - const target = comments.get(commentId); - if (!target) return; - // Replay also enforces this; failing fast here avoids posting events - // the replay would silently drop. - if (target.event.author_name !== reviewerName) return; - const event: RetractionEvent = { - kind: 'retraction', - id: `x-${crypto.randomUUID()}`, - comment_id: commentId, - author_name: reviewerName, - created_at: new Date().toISOString() - }; - await postEvent(event); - const next = new Map(comments); - next.set(commentId, { ...target, status: 'retracted' }); - comments = next; - } - - function handleSelection(sel: SelectionInfo | null) { - if (!sel) { - if (!popoverMode && !showQuickLabel) activeSelection = null; - return; - } - activeSelection = sel; - popoverMode = null; - showQuickLabel = false; - } - - function handleMarkClick(id: string) { - activeMarkId = activeMarkId === id ? undefined : id; - document - .querySelector(`[data-anno-card-id="${id}"]`) - ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - - function handleCardClick(id: string) { - activeMarkId = activeMarkId === id ? undefined : id; - } - - - - {plan?.title ?? 'Plan review'} · arc - - - diff --git a/web/src/routes/share/[id]/+page.ts b/web/src/routes/share/[id]/+page.ts deleted file mode 100644 index 60375fa..0000000 --- a/web/src/routes/share/[id]/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageLoad } from './$types'; - -export const ssr = false; // SPA-only; we need window.location.hash and crypto.subtle - -export const load: PageLoad = async ({ params }) => { - return { id: params.id }; -}; diff --git a/web/src/routes/share/[id]/components/AnnotationCard.svelte b/web/src/routes/share/[id]/components/AnnotationCard.svelte deleted file mode 100644 index f4d9369..0000000 --- a/web/src/routes/share/[id]/components/AnnotationCard.svelte +++ /dev/null @@ -1,493 +0,0 @@ - - -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} -> -
-
- - {e.author_name} - {#if isMine} - (me) - {/if} -
- - {timeLabel(e.created_at)}{#if entry.editedAt} - · edited {timeLabel(entry.editedAt)} - {/if} - -
- -
- - - {chipLabel} - - {#if entry.status !== 'open'} - - · {entry.status} - - {/if} -
- - -
- "{e.anchor.quoted_text}" -
- - - {#if isEditing} -
ev.stopPropagation()} - onkeydown={(ev) => ev.stopPropagation()} - role="presentation" - > - {#if e.suggested_text !== undefined} - - {/if} - -
-
- {modKey} ⏎ - to save · esc to cancel -
-
- - -
-
-
- {:else if e.suggested_text} -
-
- Replacement -
-
- {e.suggested_text} -
-
- {#if e.body} -
{e.body}
- {/if} - {:else if e.body} -
{e.body}
- {/if} - - {#if entry.reply} -
- ↪ {entry.reply} -
- {/if} - - - {#if (canEdit || canRetract || isAuthor) && !isEditing && !showRejectReply && !showRetractConfirm} -
- {#if canEdit} - - {/if} - - {#if canRetract} - - {/if} - - {#if (canEdit || canRetract) && isAuthor} - - {/if} - - {#if isAuthor} - {#if entry.status !== 'accepted'} - - {/if} - {#if entry.status !== 'resolved'} - - {/if} - {#if entry.status !== 'rejected'} - - {/if} - {#if entry.status !== 'open' && entry.status !== 'reopened'} - - {/if} - {/if} -
- {/if} - - {#if showRejectReply} -
- -
- - -
-
- {/if} - - {#if showRetractConfirm} - -
-

- Take this annotation back? It disappears from the rail and is excluded from - arc share comments output. -

-
- - -
-
- {/if} -
diff --git a/web/src/routes/share/[id]/components/AnnotationsPanel.svelte b/web/src/routes/share/[id]/components/AnnotationsPanel.svelte deleted file mode 100644 index 9c170ea..0000000 --- a/web/src/routes/share/[id]/components/AnnotationsPanel.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/web/src/routes/share/[id]/components/CommentPopover.svelte b/web/src/routes/share/[id]/components/CommentPopover.svelte deleted file mode 100644 index f2ff294..0000000 --- a/web/src/routes/share/[id]/components/CommentPopover.svelte +++ /dev/null @@ -1,157 +0,0 @@ - - - diff --git a/web/src/routes/share/[id]/components/FloatingToolbar.svelte b/web/src/routes/share/[id]/components/FloatingToolbar.svelte deleted file mode 100644 index 696d3ad..0000000 --- a/web/src/routes/share/[id]/components/FloatingToolbar.svelte +++ /dev/null @@ -1,324 +0,0 @@ - - - diff --git a/web/src/routes/share/[id]/components/NamePromptModal.svelte b/web/src/routes/share/[id]/components/NamePromptModal.svelte deleted file mode 100644 index 8e4e1f5..0000000 --- a/web/src/routes/share/[id]/components/NamePromptModal.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/web/src/routes/share/[id]/components/PlanRenderer.svelte b/web/src/routes/share/[id]/components/PlanRenderer.svelte deleted file mode 100644 index 5c7206a..0000000 --- a/web/src/routes/share/[id]/components/PlanRenderer.svelte +++ /dev/null @@ -1,211 +0,0 @@ - - - - -
- - {@html html} -
diff --git a/web/src/routes/share/[id]/components/QuickLabelPicker.svelte b/web/src/routes/share/[id]/components/QuickLabelPicker.svelte deleted file mode 100644 index d045350..0000000 --- a/web/src/routes/share/[id]/components/QuickLabelPicker.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/web/src/routes/share/[id]/components/inline-annotations.test.ts b/web/src/routes/share/[id]/components/inline-annotations.test.ts deleted file mode 100644 index bd4700c..0000000 --- a/web/src/routes/share/[id]/components/inline-annotations.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, beforeEach } from 'vitest'; -import { applyInlineAnnotations, type InlineMark } from './inline-annotations'; - -function el(tag: string, opts: { dataSourceLine?: number; text?: string } = {}): HTMLElement { - const e = document.createElement(tag); - if (opts.dataSourceLine !== undefined) { - e.setAttribute('data-source-line', String(opts.dataSourceLine)); - } - if (opts.text !== undefined) e.appendChild(document.createTextNode(opts.text)); - return e; -} - -function makeContainer(): HTMLElement { - return document.createElement('article'); -} - -function mark(opts: Partial & Pick): InlineMark { - return { - id: opts.id ?? 'm1', - kind: opts.kind ?? 'comment', - lineStart: opts.lineStart, - lineEnd: opts.lineEnd, - quotedText: opts.quotedText - }; -} - -describe('applyInlineAnnotations', () => { - let container: HTMLElement; - beforeEach(() => { - container = makeContainer(); - }); - - it('wraps a single contiguous selection inside one paragraph', () => { - const p = el('p', { dataSourceLine: 1, text: 'The quick brown fox' }); - container.appendChild(p); - - applyInlineAnnotations(container, [ - mark({ quotedText: 'quick brown', lineStart: 1, lineEnd: 1 }) - ]); - - const marks = container.querySelectorAll('mark.anno-comment'); - expect(marks.length).toBe(1); - expect(marks[0].textContent).toBe('quick brown'); - expect(marks[0].parentElement?.tagName).toBe('P'); - }); - - it('wraps text inside an inline element (e.g. ) without splitting structure', () => { - const p = el('p', { dataSourceLine: 1 }); - p.appendChild(document.createTextNode('use ')); - const code = el('code', { text: 'foo()' }); - p.appendChild(code); - p.appendChild(document.createTextNode(' here')); - container.appendChild(p); - - applyInlineAnnotations(container, [ - mark({ quotedText: 'use foo() here', lineStart: 1, lineEnd: 1 }) - ]); - - // The whole phrase should be wrapped — possibly as a single Range surround - // (one ) or as per-text-node wraps (three s). Either is OK - // as long as the visible covered text is exactly the needle. - const marks = Array.from(container.querySelectorAll('mark.anno-comment')); - expect(marks.length).toBeGreaterThanOrEqual(1); - const covered = marks.map((m) => m.textContent ?? '').join(''); - expect(covered).toBe('use foo() here'); - // Crucially, is still present and still contains its text. - expect(container.querySelector('code')?.textContent).toBe('foo()'); - }); - - it('wraps a multi-paragraph selection where the needle has \\n separators', () => { - const p1 = el('p', { dataSourceLine: 1, text: 'First paragraph.' }); - const p2 = el('p', { dataSourceLine: 3, text: 'Second paragraph.' }); - container.appendChild(p1); - container.appendChild(p2); - - applyInlineAnnotations(container, [ - mark({ quotedText: 'First paragraph.\nSecond paragraph.', lineStart: 1, lineEnd: 3 }) - ]); - - const marks = Array.from(container.querySelectorAll('mark.anno-comment')); - expect(marks.length).toBeGreaterThanOrEqual(2); - expect(marks.some((m) => m.textContent === 'First paragraph.')).toBe(true); - expect(marks.some((m) => m.textContent === 'Second paragraph.')).toBe(true); - // Paragraph elements remain intact. - expect(container.querySelectorAll('p').length).toBe(2); - }); - - it('wraps a heading + bulleted list selection (the regression case)', () => { - // Reproduces the "Non-goals" failure from the handoff:

followed by - // a
    with several
  • s. The TreeWalker yields text nodes with no - // whitespace between them, but selection.toString() inserts \n between - // the heading and each
  • — so the search needs synthetic block - // separators to match. - const h = el('h2', { dataSourceLine: 1, text: 'Non-goals' }); - const ul = el('ul', { dataSourceLine: 3 }); - const li1 = el('li', { text: 'Real authentication, OAuth, or accounts' }); - const li2 = el('li', { text: 'Server-side comment aggregation' }); - const li3 = el('li', { text: 'Live multi-user co-editing' }); - ul.appendChild(li1); - ul.appendChild(li2); - ul.appendChild(li3); - container.appendChild(h); - container.appendChild(ul); - - const needle = [ - 'Non-goals', - 'Real authentication, OAuth, or accounts', - 'Server-side comment aggregation', - 'Live multi-user co-editing' - ].join('\n'); - - applyInlineAnnotations(container, [mark({ quotedText: needle, lineStart: 1, lineEnd: 3 })]); - - const marks = Array.from(container.querySelectorAll('mark.anno-comment')); - // One mark per text node (heading + 3 list items). - expect(marks.length).toBe(4); - expect(marks.map((m) => m.textContent ?? '')).toEqual([ - 'Non-goals', - 'Real authentication, OAuth, or accounts', - 'Server-side comment aggregation', - 'Live multi-user co-editing' - ]); - // List structure is preserved — three
  • children of one
      . - expect(container.querySelectorAll('ul > li').length).toBe(3); - }); - - it('wraps a code-block selection containing literal newlines', () => { - //
      ...
      — text contains real \n chars, not block - // boundaries. The needle from selection.toString() also has real \n. - const pre = el('pre', { dataSourceLine: 1 }); - const code = el('code'); - code.appendChild(document.createTextNode('line one\nline two\nline three')); - pre.appendChild(code); - container.appendChild(pre); - - applyInlineAnnotations(container, [ - mark({ quotedText: 'line one\nline two\nline three', lineStart: 1, lineEnd: 1 }) - ]); - - const marks = Array.from(container.querySelectorAll('mark.anno-comment')); - expect(marks.length).toBeGreaterThanOrEqual(1); - const covered = marks.map((m) => m.textContent ?? '').join(''); - expect(covered).toBe('line one\nline two\nline three'); - //
       structure preserved.
      -		expect(container.querySelector('pre > code')).toBeTruthy();
      -	});
      -
      -	it('treats 
      as a line break (the markdown hard-break case)', () => { - // Markdown's two-trailing-spaces hard break renders as
      inside a - // single
    • or

      . Selection.toString() emits '\n' at the
      ; the - // TreeWalker SHOW_TEXT path doesn't see it. Without explicit handling - // the search whiffs because needle has '\n' but searchSpace doesn't. - const ul = el('ul', { dataSourceLine: 1 }); - const li = el('li'); - li.appendChild(document.createTextNode('they remain as')); - li.appendChild(document.createElement('br')); - li.appendChild(document.createTextNode('legacy storage')); - ul.appendChild(li); - container.appendChild(ul); - - applyInlineAnnotations(container, [ - mark({ quotedText: 'they remain as\nlegacy storage', lineStart: 1, lineEnd: 1 }) - ]); - - const marks = Array.from(container.querySelectorAll('mark.anno-comment')); - // Either a single Range wrap (1 mark covering text+
      +text) or per- - // text-node fallback (2 marks). Both are correct outcomes; what matters - // is that *something* wrapped, the structural
      survived, and both - // halves of the text are inside a mark. - expect(marks.length).toBeGreaterThanOrEqual(1); - const covered = marks.map((m) => m.textContent ?? '').join('|'); - expect(covered).toContain('they remain as'); - expect(covered).toContain('legacy storage'); - expect(container.querySelectorAll('br').length).toBe(1); - }); - - it('handles a partial selection inside a single

    • ', () => { - const ul = el('ul', { dataSourceLine: 1 }); - ul.appendChild(el('li', { text: 'one apple' })); - ul.appendChild(el('li', { text: 'two oranges' })); - container.appendChild(ul); - - applyInlineAnnotations(container, [ - mark({ quotedText: 'two oranges', lineStart: 1, lineEnd: 1 }) - ]); - - const marks = Array.from(container.querySelectorAll('mark.anno-comment')); - expect(marks.length).toBe(1); - expect(marks[0].textContent).toBe('two oranges'); - expect(container.querySelectorAll('ul > li').length).toBe(2); - }); - - it('clears prior marks on re-application', () => { - const p = el('p', { dataSourceLine: 1, text: 'Hello world' }); - container.appendChild(p); - - const m1: InlineMark = mark({ quotedText: 'Hello', lineStart: 1, lineEnd: 1, id: 'a' }); - const m2: InlineMark = mark({ quotedText: 'world', lineStart: 1, lineEnd: 1, id: 'b' }); - - applyInlineAnnotations(container, [m1]); - expect(container.querySelectorAll('mark[data-anno-id="a"]').length).toBe(1); - - applyInlineAnnotations(container, [m2]); - // First mark torn down, second applied. - expect(container.querySelectorAll('mark[data-anno-id="a"]').length).toBe(0); - expect(container.querySelectorAll('mark[data-anno-id="b"]').length).toBe(1); - }); - - it('returns silently when the anchor is missing', () => { - const p = el('p', { dataSourceLine: 1, text: 'Hello' }); - container.appendChild(p); - - applyInlineAnnotations(container, [ - mark({ quotedText: 'goodbye', lineStart: 1, lineEnd: 1 }) - ]); - - expect(container.querySelectorAll('mark.anno-comment').length).toBe(0); - // Original text untouched. - expect(p.textContent).toBe('Hello'); - }); -}); diff --git a/web/src/routes/share/[id]/components/inline-annotations.ts b/web/src/routes/share/[id]/components/inline-annotations.ts deleted file mode 100644 index 8ca0302..0000000 --- a/web/src/routes/share/[id]/components/inline-annotations.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * Apply inline annotation marks to already-rendered markdown. - * - * The hard problem: selection.toString() inserts \n separators at block - * boundaries (between
    • s, between

      s, after a heading) and at
      - * elements, but a TreeWalker over the same DOM yields just the text-node - * data with no separators. Naively searching the concatenated text-node - * string for the needle, or even a whitespace-normalized version of it, - * fails when the gap between blocks is zero whitespace — normalization can - * only collapse existing runs. - * - * Strategy: for each annotation, gather the [data-source-line] blocks in - * [lineStart, lineEnd], walk all their text nodes plus
      /


      elements - * in document order, and build TWO parallel strings: - * - * - `acc`: raw concatenation of text-node data. Cumulative offsets into - * this string map directly into textNodes via length math — used by the - * wrap step. - * - `searchSpace`: same content but with a synthetic '\n' inserted at - * every block-level boundary AND every
      /
      element. This mirrors - * what selection.toString() emits, so the needle can match. - * `searchToAcc[i]` maps every searchSpace index to its acc index, with - * -1 marking a synthetic boundary char. - * - * Search is two-tier on `searchSpace`: exact indexOf, then whitespace- - * normalized fallback for cases like extra trailing whitespace. The hit's - * range gets mapped back through `searchToAcc` (skipping synthetic chars) - * before reaching the wrap step. - * - * Wrapping is also two-tier: - * - * 1. Range surroundContents on a single Range from start text node to end - * text node — clean for ranges within one inline-friendly element. - * 2. Per-text-node wrap when (1) fails because the range crosses sibling - * block elements like
    • s. surroundContents throws in that case; - * falling back to extractContents would rip the list structure apart, - * so we instead wrap the matched slice of each individual text node - * (which is always inline-safe). - * - * The implementation rebuilds the marks every render rather than diffing. - */ - -export type InlineMark = { - id: string; - kind: 'comment' | 'delete'; - lineStart: number; - lineEnd: number; - quotedText: string; -}; - -const CLASS_BY_KIND: Record = { - comment: 'anno-comment', - delete: 'anno-delete' -}; - -export function applyInlineAnnotations( - container: HTMLElement, - marks: InlineMark[], - activeId?: string -): void { - // Tear down any prior marks so we don't double-wrap on re-render. - clearMarks(container); - - for (const mark of marks) { - const isActive = mark.id === activeId; - const blocks: HTMLElement[] = []; - for (let line = mark.lineStart; line <= mark.lineEnd; line++) { - const b = container.querySelector(`[data-source-line="${line}"]`); - if (b) blocks.push(b); - } - if (blocks.length === 0) continue; - wrapNeedleAcrossBlocks(blocks, mark.quotedText, mark, isActive); - } -} - -function clearMarks(container: HTMLElement): void { - const marks = container.querySelectorAll('mark[data-anno-id]'); - for (const m of marks) { - const parent = m.parentNode; - if (!parent) continue; - while (m.firstChild) parent.insertBefore(m.firstChild, m); - parent.removeChild(m); - // Merge adjacent text nodes that were split when we wrapped. - parent.normalize(); - } -} - -function wrapNeedleAcrossBlocks( - blocks: HTMLElement[], - needle: string, - mark: InlineMark, - isActive: boolean -): void { - if (!needle) return; - - // Walk text nodes AND inline structural elements (
      /
      ) across all - // blocks in document order. We build: - // - // - `acc`: raw text-node concatenation (textNode position math basis). - // - `searchSpace`: acc with synthetic '\n' at: - // * block-level boundaries (different closest-block ancestor), and - // *
      /
      elements (invisible to SHOW_TEXT but they produce a - // '\n' in Selection.toString()). - // Mirrors what selection.toString() emits so the needle can match. - const textNodes: Text[] = []; - let acc = ''; - let searchSpace = ''; - const searchToAcc: number[] = []; - let prevBlock: Element | null = null; - const filter: NodeFilter = { - acceptNode(node) { - if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; - if ( - node.nodeType === Node.ELEMENT_NODE && - LINE_BREAK_TAGS.has((node as Element).tagName) - ) { - return NodeFilter.FILTER_ACCEPT; - } - return NodeFilter.FILTER_SKIP; - } - }; - for (const block of blocks) { - const walker = document.createTreeWalker( - block, - NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, - filter - ); - while (walker.nextNode()) { - const node = walker.currentNode; - if (node.nodeType === Node.ELEMENT_NODE) { - if (acc.length > 0) { - searchSpace += '\n'; - searchToAcc.push(-1); - } - continue; - } - const t = node as Text; - const curBlock = closestBlockAncestor(t); - if (prevBlock && curBlock !== prevBlock) { - searchSpace += '\n'; - searchToAcc.push(-1); - } - prevBlock = curBlock; - textNodes.push(t); - const baseAcc = acc.length; - for (let i = 0; i < t.data.length; i++) { - searchSpace += t.data[i]; - searchToAcc.push(baseAcc + i); - } - acc += t.data; - } - } - if (textNodes.length === 0) return; - - // Two-tier search against searchSpace (which mirrors selection.toString()). - let searchStart = -1; - let searchEnd = -1; - const exactOffset = searchSpace.indexOf(needle); - if (exactOffset >= 0) { - searchStart = exactOffset; - searchEnd = exactOffset + needle.length; - } else { - const norm = normalizeWithMap(searchSpace); - const needleNorm = normalizeWS(needle); - if (!needleNorm) return; - const normOffset = norm.normalized.indexOf(needleNorm); - if (normOffset < 0) return; - searchStart = norm.rawPositions[normOffset]; - const lastNormIdx = normOffset + needleNorm.length - 1; - if (lastNormIdx >= norm.rawPositions.length) return; - searchEnd = norm.rawPositions[lastNormIdx] + 1; - } - - // Map searchSpace [start, end) back to acc, skipping synthetic boundary - // chars. The first real char at-or-after searchStart anchors `start`; - // the last real char before searchEnd anchors `end`. - let start = -1; - for (let i = searchStart; i < searchToAcc.length; i++) { - if (searchToAcc[i] >= 0) { - start = searchToAcc[i]; - break; - } - } - let end = -1; - for (let i = searchEnd - 1; i >= 0; i--) { - if (searchToAcc[i] >= 0) { - end = searchToAcc[i] + 1; - break; - } - } - if (start < 0 || end <= start) return; - - // Wrap. Try the contiguous Range first; on failure (range crosses sibling - // block elements like
    • s), fall back to per-text-node wrap. - if (!tryRangeWrap(textNodes, start, end, mark, isActive)) { - wrapPerTextNode(textNodes, start, end, mark, isActive); - } -} - -// Tags that produce a literal '\n' in Selection.toString() despite having no -// text-node children. We have to walk SHOW_ELEMENT to see them and translate -// each into a synthetic separator. -const LINE_BREAK_TAGS = new Set(['BR', 'HR']); - -// Tags treated as inline for the purpose of "did selection.toString() insert -// a \n between these two text nodes?". Anything not in this set is treated -// as a block boundary (paragraphs, list items, headings, code blocks, table -// cells, etc.). Matches the HTML spec's "phrasing content" tags that the -// markdown renderer can plausibly emit. -const INLINE_TAGS = new Set([ - 'A', - 'ABBR', - 'B', - 'BDI', - 'BDO', - 'BR', - 'CITE', - 'CODE', - 'DATA', - 'DEL', - 'DFN', - 'EM', - 'I', - 'INS', - 'KBD', - 'MARK', - 'Q', - 'S', - 'SAMP', - 'SMALL', - 'SPAN', - 'STRONG', - 'SUB', - 'SUP', - 'TIME', - 'U', - 'VAR', - 'WBR' -]); - -function closestBlockAncestor(node: Node): Element | null { - let cur: Node | null = node.parentNode; - while (cur && cur.nodeType === 1) { - if (!INLINE_TAGS.has((cur as Element).tagName)) return cur as Element; - cur = cur.parentNode; - } - return null; -} - -/** - * Attempts to wrap [start, end) of the concatenated text-node string in a - * single Range surroundContents. Works for ranges that don't cross sibling - * block elements. Returns false on any failure so the caller can fall back. - */ -function tryRangeWrap( - textNodes: Text[], - start: number, - end: number, - mark: InlineMark, - isActive: boolean -): boolean { - let cum = 0; - let startNode: Text | null = null; - let startInner = 0; - let endNode: Text | null = null; - let endInner = 0; - for (const t of textNodes) { - const next = cum + t.data.length; - if (startNode === null && start < next) { - startNode = t; - startInner = start - cum; - } - if (endNode === null && end <= next) { - endNode = t; - endInner = end - cum; - break; - } - cum = next; - } - if (!startNode || !endNode) return false; - - try { - const range = document.createRange(); - range.setStart(startNode, startInner); - range.setEnd(endNode, endInner); - const wrapper = createMarkWrapper(mark, isActive); - range.surroundContents(wrapper); - return true; - } catch { - // surroundContents throws when the range crosses element boundaries - // it can't surround (e.g., from inside one
    • to inside another). - // We deliberately do NOT call extractContents here — that would rip - // the structure apart and put the list contents into one flat . - return false; - } -} - -/** - * Wraps the [start, end) slice of each text node that overlaps that range. - * Each text-node range is its own atom: surroundContents on a single text - * node is always safe regardless of the surrounding element structure, so - * this preserves
    • /

      / boundaries when the contiguous Range path - * couldn't wrap across them. - */ -function wrapPerTextNode( - textNodes: Text[], - start: number, - end: number, - mark: InlineMark, - isActive: boolean -): void { - let cum = 0; - for (const t of textNodes) { - const tStart = cum; - const tEnd = cum + t.data.length; - cum = tEnd; - const overlapStart = Math.max(start, tStart); - const overlapEnd = Math.min(end, tEnd); - if (overlapStart >= overlapEnd) continue; - const localStart = overlapStart - tStart; - const localEnd = overlapEnd - tStart; - // Skip slices that are pure whitespace (e.g., a "\n" between code-block - // lines that contributed only to the separator). They shouldn't get - // their own visible . - if (!t.data.slice(localStart, localEnd).trim()) continue; - if (!t.parentNode) continue; - try { - const range = document.createRange(); - range.setStart(t, localStart); - range.setEnd(t, localEnd); - const wrapper = createMarkWrapper(mark, isActive); - range.surroundContents(wrapper); - } catch { - // A text-node range shouldn't fail surroundContents under normal DOM, - // but if it does, skip rather than throw. - } - } -} - -function createMarkWrapper(mark: InlineMark, isActive: boolean): HTMLElement { - const wrapper = document.createElement('mark'); - wrapper.className = CLASS_BY_KIND[mark.kind] + (isActive ? ' is-active' : ''); - wrapper.dataset.annoId = mark.id; - return wrapper; -} - -/** - * Collapses runs of whitespace in `s` to single spaces (trimming ends), - * returning the normalized string and a map from each normalized index to - * its corresponding index in the original string. Used to bridge the gap - * between needle (with \n separators from selection.toString()) and the - * flat text walked via TreeWalker (no separators). - */ -function normalizeWithMap(s: string): { normalized: string; rawPositions: number[] } { - let normalized = ''; - const rawPositions: number[] = []; - let prevWS = true; // skip leading whitespace - for (let i = 0; i < s.length; i++) { - const ch = s[i]; - if (isWS(ch)) { - if (!prevWS) { - normalized += ' '; - rawPositions.push(i); - prevWS = true; - } - } else { - normalized += ch; - rawPositions.push(i); - prevWS = false; - } - } - if (normalized.endsWith(' ')) { - normalized = normalized.slice(0, -1); - rawPositions.pop(); - } - return { normalized, rawPositions }; -} - -function normalizeWS(s: string): string { - return s.replace(/\s+/g, ' ').trim(); -} - -function isWS(ch: string): boolean { - return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v'; -} diff --git a/web/src/routes/share/[id]/components/platform.test.ts b/web/src/routes/share/[id]/components/platform.test.ts deleted file mode 100644 index 3eb17f6..0000000 --- a/web/src/routes/share/[id]/components/platform.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { isMacLike, modifierGlyph } from './platform'; - -// Snapshot the original navigator so each test starts from a clean baseline. -// We replace it wholesale (rather than mutate) because `navigator.platform` -// is read-only in some environments. -const originalNavigator = globalThis.navigator; - -// `Navigator.platform` is typed as a narrow literal union by TypeScript's -// DOM lib, but the runtime value is a free-form string and our helper does a -// case-insensitive substring check. Use a loose record so tests can stub -// arbitrary platform strings (e.g. "macppc", "Linux x86_64") without -// fighting the type system. -function setNavigator(stub: { platform?: string; userAgentData?: { platform?: string } }) { - Object.defineProperty(globalThis, 'navigator', { - value: stub, - writable: true, - configurable: true - }); -} - -describe('isMacLike', () => { - beforeEach(() => { - // Default: an empty navigator so individual tests opt into a platform. - setNavigator({ platform: '' }); - }); - - afterEach(() => { - Object.defineProperty(globalThis, 'navigator', { - value: originalNavigator, - writable: true, - configurable: true - }); - }); - - it('returns true when userAgentData.platform is "macOS"', () => { - setNavigator({ platform: 'unused', userAgentData: { platform: 'macOS' } }); - expect(isMacLike()).toBe(true); - }); - - it('returns false when userAgentData.platform is non-macOS', () => { - setNavigator({ platform: 'MacIntel', userAgentData: { platform: 'Linux' } }); - // Modern UA-Client-Hints take priority — even if the deprecated - // `platform` string lies, we trust the new API first. - expect(isMacLike()).toBe(false); - }); - - it('falls back to navigator.platform when userAgentData is absent', () => { - setNavigator({ platform: 'MacIntel' }); - expect(isMacLike()).toBe(true); - }); - - it('matches case-insensitively in the legacy fallback', () => { - setNavigator({ platform: 'macppc' }); - expect(isMacLike()).toBe(true); - }); - - it('returns false for Linux', () => { - setNavigator({ platform: 'Linux x86_64' }); - expect(isMacLike()).toBe(false); - }); - - it('returns false for Windows', () => { - setNavigator({ platform: 'Win32' }); - expect(isMacLike()).toBe(false); - }); -}); - -describe('modifierGlyph', () => { - beforeEach(() => { - setNavigator({ platform: '' }); - }); - - afterEach(() => { - Object.defineProperty(globalThis, 'navigator', { - value: originalNavigator, - writable: true, - configurable: true - }); - }); - - it('returns ⌘ on macOS', () => { - setNavigator({ platform: 'MacIntel' }); - expect(modifierGlyph()).toBe('⌘'); - }); - - it('returns Ctrl elsewhere', () => { - setNavigator({ platform: 'Linux' }); - expect(modifierGlyph()).toBe('Ctrl'); - }); -}); diff --git a/web/src/routes/share/[id]/components/platform.ts b/web/src/routes/share/[id]/components/platform.ts deleted file mode 100644 index e94e1b5..0000000 --- a/web/src/routes/share/[id]/components/platform.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Platform detection for displaying the right keyboard-shortcut glyph. - * - * Mac users expect ⌘ ⏎; everyone else expects Ctrl ⏎. The keyboard - * handlers already accept both `metaKey` and `ctrlKey`, so this only - * affects the on-screen hint. - * - * Detection strategy: - * 1. `navigator.userAgentData.platform` — modern, available in Chromium. - * 2. `navigator.platform` — deprecated but still populated by every - * browser. We use `.toLowerCase().includes('mac')` to also catch - * the older "MacIntel" / "MacPPC" values. - * 3. SSR fallback — return false. The first client paint will correct - * itself; SvelteKit's `+page.svelte` renders client-side after the - * paste blob decrypts anyway, so we never actually hit SSR for the - * share UI. - */ -export function isMacLike(): boolean { - if (typeof navigator === 'undefined') return false; - type UAData = { platform?: string }; - const uaData = (navigator as Navigator & { userAgentData?: UAData }).userAgentData; - if (uaData?.platform) { - return uaData.platform === 'macOS'; - } - return navigator.platform.toLowerCase().includes('mac'); -} - -/** - * Glyph shown for the modifier in ` ⏎` keyboard hints. - * `⌘` on macOS, `Ctrl` everywhere else. - */ -export function modifierGlyph(): string { - return isMacLike() ? '⌘' : 'Ctrl'; -} diff --git a/web/src/routes/share/[id]/components/positioning.test.ts b/web/src/routes/share/[id]/components/positioning.test.ts deleted file mode 100644 index 80a6da2..0000000 --- a/web/src/routes/share/[id]/components/positioning.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { clampedAnchorLeft } from './positioning'; - -describe('clampedAnchorLeft', () => { - it('returns the raw center when the overlay fits without clipping', () => { - // 360px overlay at center 600 in a 1200px viewport: extends 420..780, - // clear of both 8px margins. - expect(clampedAnchorLeft(600, 360, 1200)).toBe(600); - }); - - it('pushes overlay rightward when selection is too close to the left edge', () => { - // 360px overlay anchored at center 50 would extend -130..230, clipping - // past the left edge. Clamp must push the center to 8 + 180 = 188. - expect(clampedAnchorLeft(50, 360, 1200)).toBe(188); - }); - - it('pushes overlay leftward when selection is too close to the right edge', () => { - // 360px overlay anchored at center 1180 would extend 1000..1360, - // clipping past the right edge. Clamp must pull center to 1200 - 8 - 180 = 1012. - expect(clampedAnchorLeft(1180, 360, 1200)).toBe(1012); - }); - - it('respects a custom margin', () => { - // With a 20px margin, the left clamp is 20 + 180 = 200. - expect(clampedAnchorLeft(50, 360, 1200, 20)).toBe(200); - }); - - it('falls back to pinning when overlay is wider than viewport', () => { - // 800px overlay in a 600px viewport can never satisfy both bounds. - // Behavior: pin to left margin (start of controls visible). - expect(clampedAnchorLeft(300, 800, 600)).toBe(8 + 400); - }); - - it('handles selection exactly at the left clamp threshold', () => { - // 360px overlay, viewport 1200, margin 8 → minLeft = 188. At 188 no clamp needed. - expect(clampedAnchorLeft(188, 360, 1200)).toBe(188); - }); -}); diff --git a/web/src/routes/share/[id]/components/positioning.ts b/web/src/routes/share/[id]/components/positioning.ts deleted file mode 100644 index ff615b9..0000000 --- a/web/src/routes/share/[id]/components/positioning.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Positioning helpers for the floating overlays (toolbar, popover, picker). - * - * Each overlay anchors above the user's selection. The naive computation — - * `rect.left + rect.width/2` paired with `transform: translateX(-50%)` — - * works in isolation but clips the overlay off-screen when the selection - * is near the viewport's left or right edge. With the share page's - * asymmetric inset (doc sits ~72px from the left), a comment popover - * (360px wide) anchored to a selection at the start of the prose column - * extends ~108px past the left edge of the viewport. - * - * `clampedAnchorLeft` returns the `left` value to plug into the overlay's - * inline style. The caller still applies `translateX(-50%)`. After clamping, - * the overlay's edges are guaranteed to sit within `[margin, viewportWidth - - * margin]`. If the overlay is wider than the viewport, we fall back to - * pinning it to the left margin (degenerate but never broken). - */ -export function clampedAnchorLeft( - selectionCenter: number, - overlayWidth: number, - viewportWidth: number, - margin = 8 -): number { - const half = overlayWidth / 2; - const minLeft = margin + half; - const maxLeft = viewportWidth - margin - half; - if (minLeft > maxLeft) { - // Overlay wider than viewport — pin to the left margin so the user - // at least sees the start of the controls. They can scroll if needed. - return margin + half; - } - return Math.min(Math.max(selectionCenter, minLeft), maxLeft); -} diff --git a/web/vite.config.ts b/web/vite.config.ts index 3ddaa7e..0abd395 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,10 +6,8 @@ export default defineConfig({ plugins: [tailwindcss(), sveltekit()], server: { proxy: { - // Backend target overridable via ARC_PASTE_BACKEND so the dev server - // can drive a non-default port (e.g. arc-paste running on :7436). '/api': { - target: process.env.ARC_PASTE_BACKEND ?? 'http://localhost:7432', + target: 'http://localhost:7432', changeOrigin: true } } From ad45a57df9835dce1f8a87e5edda57f38cd027ba Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:15:07 -0700 Subject: [PATCH 03/11] refactor: remove arc-paste standalone service --- Makefile | 5 - arc-paste/Caddyfile | 53 ---------- arc-paste/Dockerfile | 59 ------------ arc-paste/README.md | 43 --------- arc-paste/compose.yaml | 69 ------------- arc-paste/main.go | 131 ------------------------- arc-paste/main_test.go | 144 ---------------------------- internal/paste/cmd/genxlang/main.go | 54 ----------- 8 files changed, 558 deletions(-) delete mode 100644 arc-paste/Caddyfile delete mode 100644 arc-paste/Dockerfile delete mode 100644 arc-paste/README.md delete mode 100644 arc-paste/compose.yaml delete mode 100644 arc-paste/main.go delete mode 100644 arc-paste/main_test.go delete mode 100644 internal/paste/cmd/genxlang/main.go diff --git a/Makefile b/Makefile index c678f33..df40361 100644 --- a/Makefile +++ b/Makefile @@ -104,11 +104,6 @@ build-bin: ## Build arc binary with embedded web UI (requires frontend built fir build-quick: ## Build CLI-only binary (no embedded web UI) $(BUILD_SCRIPT) -.PHONY: build-paste -build-paste: web-build ## Build arc-paste standalone binary (with embedded SPA) - @echo "==> Building arc-paste binary..." - $(GO) build -tags webui -o $(BIN_DIR)/arc-paste ./arc-paste - .PHONY: release release: ## Build release with goreleaser (requires git tag) goreleaser release --clean diff --git a/arc-paste/Caddyfile b/arc-paste/Caddyfile deleted file mode 100644 index b51daac..0000000 --- a/arc-paste/Caddyfile +++ /dev/null @@ -1,53 +0,0 @@ -# Caddyfile — TLS termination + reverse proxy for arc-paste. -# -# Mounted read-only at /etc/caddy/Caddyfile by compose.yaml. Caddy reads this -# automatically on startup, so the compose service has no `command:` block. -# -# To change the public hostname, edit the site address below. To change the -# ACME contact, edit `email` in the global block. Reload without downtime via: -# docker compose -f arc-paste/compose.yaml exec caddy caddy reload --config /etc/caddy/Caddyfile -{ - # Let's Encrypt sends expiry + revocation notices here. Without it, you - # only learn about cert problems when the site goes down. - email ops@company.com -} - -arcpaste.company.com { - # Negotiate compression with the client. zstd preferred, gzip fallback. - encode zstd gzip - - # Default-deny edge: arc-paste only needs to expose four things to render - # a share — the share route itself, SvelteKit's hashed asset bundle, the - # paste API, and robots.txt. Anything else (the arc app shell at /, stray - # /api/v1/* probes from the SPA, /labels, /planner/*, etc.) is rejected - # before it reaches the upstream. arc-paste/main.go has matching in-binary - # guards so dev (no Caddy) behaves the same. - # - # Note: directives inside one named-matcher block are AND'd. To OR - # different matcher *kinds* (path-list vs. regex), define them separately - # and use multiple handle blocks pointing at the same upstream. - # /api/paste covers `arc share create` (POST to bare /api/paste); - # /api/paste/* covers GET/PUT/append against an existing share id. - @assets path /_app/* /api/paste /api/paste/* /robots.txt - @share path_regexp ^/share/[^/]+$ - - handle @assets { - # arc-paste listens on the internal Docker network — the service - # name resolves via Compose's embedded DNS. - reverse_proxy arc-paste:7433 - } - handle @share { - reverse_proxy arc-paste:7433 - } - - handle { - respond "Not found" 404 - } - - # Structured access logs to stdout so `docker compose logs caddy` - # stays parseable by jq / log shippers. - log { - output stdout - format json - } -} diff --git a/arc-paste/Dockerfile b/arc-paste/Dockerfile deleted file mode 100644 index ec4aa64..0000000 --- a/arc-paste/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# syntax=docker/dockerfile:1.7 -# -# Three-stage build for arc-paste: -# 1. `web` builds the SvelteKit SPA with bun (matches local toolchain) -# 2. `go-build` compiles a fully-static Go binary with the embedded SPA. -# `-tags webui` is REQUIRED — without it `web.RegisterSPA` is a no-op -# stub and `/share/` returns Echo's default JSON 404. -# 3. Final image is `scratch` — no shell, no libc, just the binary. -# -# Build from the repo root, not arc-paste/, since stage 2 needs the Go module. -# docker build -f arc-paste/Dockerfile -t arc-paste:latest . - -# ─── Stage 1: SvelteKit SPA build ──────────────────────────────────────────── -FROM oven/bun:1-alpine AS web -WORKDIR /web - -# Install deps in their own layer so source-only changes don't bust the cache. -COPY web/package.json web/bun.lock* ./ -RUN bun install --frozen-lockfile - -COPY web/ ./ -RUN bun run build - -# ─── Stage 2: static Go binary ─────────────────────────────────────────────── -FROM golang:1.26-alpine AS go-build -WORKDIR /build - -# Module download is its own cache layer too. -COPY go.mod go.sum ./ -RUN go mod download - -# Bring in source + the SPA bundle the build tag will embed. -COPY . . -COPY --from=web /web/build ./web/build - -# CGO_ENABLED=0 → fully static, no glibc dependency, runs on scratch. -# -tags webui → embed the SPA (without it, the SPA returns 404). -# -trimpath → strip absolute paths from the binary (smaller + reproducible). -# -ldflags '-s -w' → drop DWARF + symbol tables (~20% smaller binary). -RUN CGO_ENABLED=0 GOOS=linux go build \ - -tags webui \ - -trimpath \ - -ldflags='-s -w' \ - -o /out/arc-paste \ - ./arc-paste - -# ─── Stage 3: minimal runtime ──────────────────────────────────────────────── -FROM scratch - -COPY --from=go-build /out/arc-paste /arc-paste - -# Default DB lives under /data; mount a volume here to persist across restarts. -# arc-paste creates the directory itself, so this works even without a volume. -ENV ARC_PASTE_DB=/data/arc-paste.db -ENV ARC_PASTE_ADDR=:7433 - -EXPOSE 7433 - -ENTRYPOINT ["/arc-paste"] diff --git a/arc-paste/README.md b/arc-paste/README.md deleted file mode 100644 index 6231f69..0000000 --- a/arc-paste/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# arc-paste - -A tiny standalone binary that exposes the arc paste API and serves the embedded SvelteKit SPA. Designed for public deployment as a zero-knowledge paste service for sharing arc plan reviews. - -## Building - -```bash -make build-paste -``` - -Produces `./bin/arc-paste`. - -## Running - -```bash -./bin/arc-paste -``` - -Starts the server on port 7433 by default. - -## Configuration - -- `ARC_PASTE_ADDR`: Listen address (default: `:7433`) -- `ARC_PASTE_DB`: SQLite database path (default: `./arc-paste.db`) - -## API - -The binary serves: -- `/api/paste/*` — Paste HTTP handlers (create, retrieve, update, delete pastes) -- `/` — Embedded SPA (with index.html fallback for SPA routing) - -CORS is enabled for all origins. - -## Docker - -```bash -make docker-build -docker run -p 7433:7433 arc-paste:latest -``` - -## License - -See the main arc repository. diff --git a/arc-paste/compose.yaml b/arc-paste/compose.yaml deleted file mode 100644 index b5935f1..0000000 --- a/arc-paste/compose.yaml +++ /dev/null @@ -1,69 +0,0 @@ -# arc-paste compose — standalone deployment of the encrypted paste service. -# -# Build the image and start the service: -# docker compose -f arc-paste/compose.yaml up -d --build -# Tail logs: -# docker compose -f arc-paste/compose.yaml logs -f -# Stop: -# docker compose -f arc-paste/compose.yaml down -# -# Caddy terminates HTTPS for arcpaste.company.com. Make sure DNS points to this -# host and inbound ports 80/443 are reachable for Let's Encrypt certificate -# issuance and renewal. -# -# The Dockerfile lives in arc-paste/ but builds from the repo root because it -# needs the parent Go module — that's what `context: ..` below selects. - -services: - arc-paste: - build: - context: .. - dockerfile: arc-paste/Dockerfile - image: arc-paste:latest - container_name: arc-paste - expose: - - "7433" - volumes: - # Named volume keeps the SQLite db across container rebuilds. - # arc-paste creates the directory itself on startup, so a fresh - # volume works without any init step. - - arc-paste-data:/data - environment: - - TZ=UTC - # Defaults set in the Dockerfile; uncomment to override per deployment: - # - ARC_PASTE_ADDR=:7433 - # - ARC_PASTE_DB=/data/arc-paste.db - restart: unless-stopped - # No healthcheck: the runtime image is `scratch`, which has no shell, - # wget, curl, or anything else compose's `healthcheck.test` could exec. - # For production monitoring, run an external probe (Cloudflare health - # check, Uptime Kuma, etc.) against the Caddy HTTPS endpoint instead. - - caddy: - # Pin minor version for reproducible deploys; bump deliberately when you - # want new features. Floating `:2-alpine` would silently follow upstream. - image: caddy:2.11-alpine - container_name: arc-paste-caddy - depends_on: - - arc-paste - ports: - - "80:80" - - "443:443" - - "443:443/udp" - volumes: - # Caddyfile drives the reverse proxy + ACME config. Mounted read-only - # so the container can't accidentally rewrite it on `caddy reload`. - - ./Caddyfile:/etc/caddy/Caddyfile:ro - # Persist ACME certificates and Caddy autosave config across restarts. - # Losing /data means losing the ACME account + certs — risks LE rate limits. - - caddy-data:/data - - caddy-config:/config - restart: unless-stopped - -volumes: - arc-paste-data: - name: arc-paste-data - caddy-data: - name: arc-paste-caddy-data - caddy-config: - name: arc-paste-caddy-config diff --git a/arc-paste/main.go b/arc-paste/main.go deleted file mode 100644 index 32387dd..0000000 --- a/arc-paste/main.go +++ /dev/null @@ -1,131 +0,0 @@ -// Package main is arc-paste, a tiny standalone binary that exposes only the paste API -// and serves the SvelteKit SPA. Designed for public deployment as a -// zero-knowledge paste service for arc plan reviews. -package main - -import ( - "context" - "database/sql" - "fmt" - "log" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - _ "modernc.org/sqlite" // match arc's driver - - "github.com/sentiolabs/arc/internal/paste" - pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" - "github.com/sentiolabs/arc/web" -) - -// dbDirMode is the permission used when creating the parent directory of the -// SQLite database. World-readable on purpose — the file mode itself (which -// SQLite controls) is what protects the data. -const dbDirMode = 0o755 - -func main() { - if err := run(); err != nil { - log.Fatal(err) - } -} - -func run() error { - addr := envOr("ARC_PASTE_ADDR", ":7433") - dbPath := envOr("ARC_PASTE_DB", "./arc-paste.db") - - // Ensure the parent directory exists. SQLite will create the db file but - // not the directory, which matters when running under scratch / distroless - // images that mount a fresh volume at a path the binary has never seen. - if dir := filepath.Dir(dbPath); dir != "." && dir != "/" { - if err := os.MkdirAll(dir, dbDirMode); err != nil { - return fmt.Errorf("create db dir %q: %w", dir, err) - } - } - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return fmt.Errorf("open db: %w", err) - } - defer db.Close() - - if err := pastesqlite.Apply(context.Background(), db); err != nil { - return fmt.Errorf("apply migrations: %w", err) - } - - handlers := paste.NewHandlers(pastesqlite.New(db)) - return newRouter(handlers).Start(addr) -} - -// newRouter wires the arc-paste HTTP surface. Extracted so tests can exercise -// the allowlist without binding a real listener. -func newRouter(handlers *paste.Handlers) *echo.Echo { - e := echo.New() - e.HideBanner = true - e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ - LogStatus: true, - LogURI: true, - LogMethod: true, - LogError: true, - LogValuesFunc: func(_ echo.Context, v middleware.RequestLoggerValues) error { - log.Printf("%s %s -> %d (err=%v)", v.Method, v.URI, v.Status, v.Error) - return nil - }, - })) - e.Use(middleware.Recover()) - e.Use(middleware.CORS()) - - // Default-deny everything outside the share surface. This mirrors the - // Caddyfile allowlist (arc-paste/Caddyfile) exactly so dev (no Caddy) - // and prod behave the same — without this, the SPA wildcard registered - // by web.RegisterSPA would happily serve the arc app shell at /labels, - // /dashboard, //issues, etc., even though arc-paste deploys - // have no use for any of those routes. - e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - if !arcPasteAllowedPath(c.Request().URL.Path) { - return c.String(http.StatusNotFound, "Not found") - } - return next(c) - } - }) - - handlers.Register(e.Group("/api/paste")) - web.RegisterSPA(e) - return e -} - -// arcPasteAllowedPath mirrors the Caddyfile allowlist: -// -// /_app/* SvelteKit hashed asset bundle -// /api/paste paste API (create) -// /api/paste/* paste API (per-share routes) -// /robots.txt robots -// /share/ share page (exactly one segment after /share/) -// -// Anything else is rejected with the same 404 Caddy returns at the edge. -// Keep this function in sync with arc-paste/Caddyfile if either changes. -func arcPasteAllowedPath(p string) bool { - switch p { - case "/api/paste", "/robots.txt": - return true - } - if strings.HasPrefix(p, "/_app/") || strings.HasPrefix(p, "/api/paste/") { - return true - } - if rest, ok := strings.CutPrefix(p, "/share/"); ok { - return rest != "" && !strings.Contains(rest, "/") - } - return false -} - -// envOr returns the value of env variable key, or defaultVal if not set. -func envOr(key, defaultVal string) string { - if v, ok := os.LookupEnv(key); ok { - return v - } - return defaultVal -} diff --git a/arc-paste/main_test.go b/arc-paste/main_test.go deleted file mode 100644 index 0130c90..0000000 --- a/arc-paste/main_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package main - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/sentiolabs/arc/internal/paste" - pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" - _ "modernc.org/sqlite" -) - -func newTestRouter(t *testing.T) http.Handler { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - if err := pastesqlite.Apply(context.Background(), db); err != nil { - t.Fatalf("migrate: %v", err) - } - return newRouter(paste.NewHandlers(pastesqlite.New(db))) -} - -func TestArcPasteCreate(t *testing.T) { - e := newTestRouter(t) - body, _ := json.Marshal(map[string]any{ - "plan_blob": []byte{1, 2, 3}, - "plan_iv": []byte{4, 5, 6}, - "schema_ver": 1, - }) - req := httptest.NewRequest("POST", "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d", rec.Code) - } -} - -// arc-paste deployments only legitimately serve the share surface. Anything -// outside the allowlist (paths the arc SPA shell normally owns — -// /api/v1/projects on boot, /labels, //issues, /dashboard, /, -// etc.) must 404. Without this, the SPA wildcard registered by -// web.RegisterSPA would happily return the arc app shell HTML for those -// paths, which is both confusing and (for /api/v1/* boot probes) actively -// breaks the SPA because it can't JSON.parse HTML. The test mirrors the -// Caddyfile allowlist exactly so the in-binary guard and the edge stay -// in lockstep. -func TestArcPasteAllowlist(t *testing.T) { - t.Run("rejected paths 404", func(t *testing.T) { - e := newTestRouter(t) - // A representative slice — not exhaustive. Includes the arc SPA boot - // probes and a sampling of the routes the arc app shell normally owns, - // plus shapes that look superficially like /share/ but aren't. - rejected := []string{ - "/", - "/labels", - "/dashboard", - "/teams", - "/api/v1/projects", - "/api/v1/workspaces", - "/api/v1/anything/nested", - "/share", // missing id - "/share/", // empty id - "/share/abc/sub", // multi-segment after /share/ - "/_app", // bare /_app (no trailing slash) is not the asset prefix - "/api/pasteX", // not /api/paste or /api/paste/ - "/robots.txt.bak", // not exactly /robots.txt - } - for _, path := range rejected { - rec := httptest.NewRecorder() - e.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) - if rec.Code != http.StatusNotFound { - t.Errorf("%s: expected 404, got %d (body=%q)", path, rec.Code, rec.Body.String()) - } - } - }) - - t.Run("allowed paths reach handlers", func(t *testing.T) { - e := newTestRouter(t) - // /api/paste create — the existing happy path. Round-trips a real - // CreatePasteRequest so we know the allowlist didn't accidentally - // shadow the handler. - body, _ := json.Marshal(map[string]any{ - "plan_blob": []byte{1, 2, 3}, - "plan_iv": []byte{4, 5, 6}, - "schema_ver": 1, - }) - req := httptest.NewRequest("POST", "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Errorf("/api/paste: expected 201 from handler, got %d (body=%q)", rec.Code, rec.Body.String()) - } - - // Allowlisted GET paths must NOT 404. We don't assert 200 because the - // embedded SPA may not be present in CLI-only test builds — what - // matters is that the allowlist doesn't reject them. - for _, path := range []string{"/share/abc123", "/_app/anything", "/robots.txt"} { - rec := httptest.NewRecorder() - e.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) - if rec.Code == http.StatusNotFound && rec.Body.String() == "Not found" { - t.Errorf("%s: rejected by allowlist (got 404 with allowlist body), want pass-through to handler", path) - } - } - }) -} - -// Sanity-check the allowlist predicate directly so a regression in shape -// matching is obvious without spinning up the full router. -func TestArcPasteAllowedPath(t *testing.T) { - cases := []struct { - path string - want bool - }{ - {"/api/paste", true}, - {"/api/paste/abc", true}, - {"/api/paste/abc/blobs", true}, - {"/_app/foo.js", true}, - {"/_app/immutable/chunk.abc.js", true}, - {"/robots.txt", true}, - {"/share/abc123", true}, - {"/", false}, - {"/labels", false}, - {"/share", false}, - {"/share/", false}, - {"/share/abc/sub", false}, - {"/_app", false}, - {"/api/pasteX", false}, - {"/api/v1/projects", false}, - } - for _, tc := range cases { - if got := arcPasteAllowedPath(tc.path); got != tc.want { - t.Errorf("arcPasteAllowedPath(%q) = %v, want %v", tc.path, got, tc.want) - } - } -} diff --git a/internal/paste/cmd/genxlang/main.go b/internal/paste/cmd/genxlang/main.go deleted file mode 100644 index 0146e52..0000000 --- a/internal/paste/cmd/genxlang/main.go +++ /dev/null @@ -1,54 +0,0 @@ -// genxlang generates testdata/xlang_fixtures.json with Go-encrypted blobs. -// Run once to populate; the fixtures are checked into the repo and used -// by both Go and TS tests to verify cross-language compatibility. -package main - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "os" - - "github.com/sentiolabs/arc/internal/paste" -) - -type fixture struct { - Name string `json:"name"` - KeyB64Url string `json:"key_b64url"` - Plaintext any `json:"plaintext"` - CiphertextB64 string `json:"ciphertext_b64"` - IvB64 string `json:"iv_b64"` -} - -func main() { - cases := []struct { - name string - v any - }{ - {"simple-string", "hello world"}, - {"empty-object", map[string]any{}}, - {"nested-object", map[string]any{ - "kind": "comment", "id": "c1", "author_name": "Alice", - "anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "x"}, - }}, - } - out := make([]fixture, 0, len(cases)) - for _, c := range cases { - key, _ := paste.GenerateKey() - ct, iv, err := paste.EncryptJSON(c.v, key) - if err != nil { - panic(err) - } - out = append(out, fixture{ - Name: c.name, - KeyB64Url: base64.RawURLEncoding.EncodeToString(key), - Plaintext: c.v, - CiphertextB64: base64.StdEncoding.EncodeToString(ct), - IvB64: base64.StdEncoding.EncodeToString(iv), - }) - } - data, _ := json.MarshalIndent(out, "", " ") - fmt.Println(string(data)) - const fixtureMode = 0o600 - _ = os.WriteFile("internal/paste/testdata/xlang_fixtures.json", data, fixtureMode) -} From 19691f41213853ec1373aa46ad0d257be6e919a1 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:22:23 -0700 Subject: [PATCH 04/11] refactor(api,cli): remove share/paste endpoints, CLI, wiring, and orphaned sharesconfig --- api/openapi.yaml | 121 --- cmd/arc/main.go | 8 - cmd/arc/share.go | 1009 ------------------ cmd/arc/share_test.go | 1086 -------------------- internal/api/openapi.gen.go | 539 ++-------- internal/api/paste_routes.go | 18 - internal/api/paste_routes_test.go | 87 -- internal/api/server.go | 23 - internal/api/shares.go | 62 -- internal/api/shares_import.go | 79 -- internal/api/shares_import_test.go | 382 ------- internal/api/shares_test.go | 285 ----- internal/client/shares.go | 108 -- internal/client/shares_test.go | 174 ---- internal/server/server.go | 15 - internal/sharesconfig/sharesconfig.go | 154 --- internal/sharesconfig/sharesconfig_test.go | 117 --- web/src/lib/api/types.ts | 153 --- 18 files changed, 100 insertions(+), 4320 deletions(-) delete mode 100644 cmd/arc/share.go delete mode 100644 cmd/arc/share_test.go delete mode 100644 internal/api/paste_routes.go delete mode 100644 internal/api/paste_routes_test.go delete mode 100644 internal/api/shares.go delete mode 100644 internal/api/shares_import.go delete mode 100644 internal/api/shares_import_test.go delete mode 100644 internal/api/shares_test.go delete mode 100644 internal/client/shares.go delete mode 100644 internal/client/shares_test.go delete mode 100644 internal/sharesconfig/sharesconfig.go delete mode 100644 internal/sharesconfig/sharesconfig_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 239481a..1591380 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -35,8 +35,6 @@ tags: description: AI session and agent observability - name: plans description: Ephemeral plan review artifacts - - name: shares - description: Author-side keyring of paste shares created on this machine paths: # ==================== @@ -1016,83 +1014,6 @@ paths: "500": $ref: "#/components/responses/InternalError" - # ==================== - # Shares (author keyring) - # ==================== - /shares: - get: - operationId: listShares - tags: [shares] - summary: List authored shares from the local keyring - responses: - "200": - description: List of shares (newest first) - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Share" - "500": - $ref: "#/components/responses/InternalError" - - post: - operationId: upsertShare - tags: [shares] - summary: Insert or replace a share keyring entry - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpsertShareRequest" - responses: - "200": - description: Share stored - content: - application/json: - schema: - $ref: "#/components/schemas/Share" - "400": - $ref: "#/components/responses/BadRequest" - "500": - $ref: "#/components/responses/InternalError" - - /shares/{shareId}: - parameters: - - name: shareId - in: path - required: true - description: Share ID (server-generated by the paste host) - schema: - type: string - - get: - operationId: getShare - tags: [shares] - summary: Get a single share keyring entry - responses: - "200": - description: Share record - content: - application/json: - schema: - $ref: "#/components/schemas/Share" - "404": - $ref: "#/components/responses/NotFound" - "500": - $ref: "#/components/responses/InternalError" - - delete: - operationId: deleteShare - tags: [shares] - summary: Remove a share from the keyring (idempotent) - responses: - "204": - description: Share removed (or absent — same response) - "500": - $ref: "#/components/responses/InternalError" - # ==================== # Issue-Label Associations (project-scoped) # ==================== @@ -1979,48 +1900,6 @@ components: label: type: string - # ==================== - # Share Schemas - # ==================== - Share: - type: object - required: [id, kind, url, key_b64url, edit_token, created_at] - properties: - id: - type: string - kind: - $ref: "#/components/schemas/ShareKind" - url: - type: string - key_b64url: - type: string - edit_token: - type: string - plan_file: - type: string - created_at: - type: string - format: date-time - ShareKind: - type: string - enum: [local, shared] - UpsertShareRequest: - type: object - required: [id, kind, url, key_b64url, edit_token] - properties: - id: - type: string - kind: - $ref: "#/components/schemas/ShareKind" - url: - type: string - key_b64url: - type: string - edit_token: - type: string - plan_file: - type: string - # ==================== # Comment Schemas # ==================== diff --git a/cmd/arc/main.go b/cmd/arc/main.go index d727907..4584dc3 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -17,7 +17,6 @@ import ( "github.com/sentiolabs/arc/internal/client" cfgpkg "github.com/sentiolabs/arc/internal/config" "github.com/sentiolabs/arc/internal/project" - "github.com/sentiolabs/arc/internal/sharesconfig" "github.com/sentiolabs/arc/internal/types" "github.com/sentiolabs/arc/internal/version" "github.com/spf13/cobra" @@ -262,13 +261,6 @@ func init() { rootCmd.PersistentFlags().BoolVar(&outputJSON, "json", false, "Output as JSON") rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Config file path") - // Wire sharesconfig to talk to arc-server over HTTP. The factory is - // invoked lazily so flag/env/config resolution happens at command time, - // not at process start. - sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { - return getClient() - }) - // Add commands rootCmd.AddCommand(projectCmd) rootCmd.AddCommand(listCmd) diff --git a/cmd/arc/share.go b/cmd/arc/share.go deleted file mode 100644 index f2b4696..0000000 --- a/cmd/arc/share.go +++ /dev/null @@ -1,1009 +0,0 @@ -// Package main extends the arc CLI with `arc share` commands for creating -// and managing zero-knowledge encrypted plan shares. -package main - -import ( - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/sentiolabs/arc/internal/paste" - "github.com/sentiolabs/arc/internal/sharesconfig" -) - -// --- plaintext schemas (mirror web/src/lib/paste/types.ts) --- - -type planPlaintext struct { - Version int `json:"version"` - Markdown string `json:"markdown"` - Title string `json:"title,omitempty"` - AuthorName string `json:"author_name,omitempty"` - CreatedAt string `json:"created_at"` -} - -type commentEvent struct { - Kind string `json:"kind"` - ID string `json:"id"` - AuthorName string `json:"author_name"` - CommentType string `json:"comment_type"` - // Action is the reviewer's primary intent: "comment" (default) or - // "delete" (strikethrough — body may be empty since the strikethrough - // IS the action). Preserved on round-trip so consumers like - // `arc share comments --json` can distinguish deletion requests from - // regular comments. Mirrors the AnnotationAction type in the SPA - // (web/src/lib/paste/types.ts). - Action string `json:"action,omitempty"` - Severity string `json:"severity,omitempty"` - Body string `json:"body"` - SuggestedText string `json:"suggested_text,omitempty"` - ParentID string `json:"parent_id,omitempty"` - Anchor any `json:"anchor"` - CreatedAt string `json:"created_at"` -} - -type resolutionEvent struct { - Kind string `json:"kind"` - ID string `json:"id"` - CommentID string `json:"comment_id"` - Status string `json:"status"` - Reply string `json:"reply,omitempty"` - AuthorName string `json:"author_name"` - CreatedAt string `json:"created_at"` -} - -type approvalEvent struct { - Kind string `json:"kind"` // always "approval" - ID string `json:"id"` - AuthorName string `json:"author_name"` - CreatedAt string `json:"created_at"` -} - -// commentEntry is the in-flight aggregation of a comment + its resolution -// status, used internally by printComments / emitBundle. Lives at package -// scope so both functions can refer to the same type. -type commentEntry struct { - comment commentEvent - status string - reply string -} - -// editEvent is a reviewer revising their own annotation. Replay merges the -// supplied fields onto the target comment in chronological order, gated on -// the edit's author_name matching the original comment's. See the -// EditEvent docstring in web/src/lib/paste/types.ts for the field semantics. -// -// Only `body`, `suggested_text`, and `comment_type` are editable. Pointer -// fields distinguish "field omitted (keep)" from "field set to empty -// (clear)" — Go zero values would conflate the two. -type editEvent struct { - Kind string `json:"kind"` // always "edit" - ID string `json:"id"` - CommentID string `json:"comment_id"` - AuthorName string `json:"author_name"` - Body *string `json:"body,omitempty"` - SuggestedText *string `json:"suggested_text,omitempty"` - CommentType *string `json:"comment_type,omitempty"` - CreatedAt string `json:"created_at"` -} - -// retractionEvent is the original commenter taking their annotation back. -// Replay marks the target comment with status='retracted'; the printers -// and JSON bundle filter retracted entries out of the default output so -// downstream LLM consumers don't act on revoked material. The encrypted -// event remains in the log as audit. Only an event whose author_name -// matches the target's author_name takes effect — plan author canNOT -// retract someone else's comment (they have Reject for that). -type retractionEvent struct { - Kind string `json:"kind"` // always "retraction" - ID string `json:"id"` - CommentID string `json:"comment_id"` - AuthorName string `json:"author_name"` - CreatedAt string `json:"created_at"` -} - -// --- commands --- - -var shareCmd = &cobra.Command{ - Use: "share", - Short: "Create and manage encrypted plan shares", -} - -var shareCreateCmd = &cobra.Command{ - Use: "create ", - Short: "Encrypt a plan and create a share", - Args: cobra.ExactArgs(1), - RunE: runShareCreate, -} - -var shareListCmd = &cobra.Command{ - Use: "list", - Short: "List shares known to this machine", - RunE: runShareList, -} - -var shareShowCmd = &cobra.Command{ - Use: "show ", - Short: "Decrypt and print plan content", - Args: cobra.ExactArgs(1), - RunE: runShareShow, -} - -var shareCommentsCmd = &cobra.Command{ - Use: "comments ", - Short: "Fetch and decrypt comments for a share", - Args: cobra.ExactArgs(1), - RunE: runShareComments, -} - -var sharePullCmd = &cobra.Command{ - Use: "pull ", - Short: "Pull comments (alias for `comments` with --accepted-only by default)", - Args: cobra.ExactArgs(1), - RunE: runSharePull, -} - -var shareApproveCmd = &cobra.Command{ - Use: "approve ", - Short: "Mark the share as approved", - Args: cobra.ExactArgs(1), - RunE: runShareApprove, -} - -var shareUpdateCmd = &cobra.Command{ - Use: "update ", - Short: "Replace the encrypted plan content (uses the edit_token from the local arc keyring)", - // `update` takes exactly the share ref AND the plan file path. - Args: cobra.ExactArgs(shareUpdateArgCount), - RunE: runShareUpdate, -} - -const shareUpdateArgCount = 2 - -// shareKindLocal / shareKindShared label the resolved server in the local -// arc keyring and surface in `arc share list` output. -const ( - shareKindLocal = "local" - shareKindShared = "shared" -) - -const defaultShareServer = "https://arcplanner.sentiolabs.io" - -var shareDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a share (uses the edit_token from the local arc keyring)", - Args: cobra.ExactArgs(1), - SilenceUsage: true, - RunE: runShareDelete, -} - -var ( - shareCreateRemote bool - shareCreateServer string - shareCreateAuthor string - shareCreateTitle string - shareCommentsAccepted bool - shareCommentsJSON bool - shareShowAuthorURL bool - shareDeleteForce bool -) - -func init() { - shareCreateCmd.Flags().BoolVar(&shareCreateRemote, "remote", false, - "Use the configured remote share server (precedence: --server flag > share_server in "+ - "cli-config.json > $ARC_SHARE_SERVER > built-in default). Without --remote, --server, "+ - "or an explicit URL, the share is created on the local arc-server.") - shareCreateCmd.Flags().StringVar(&shareCreateServer, "server", "", - "Server URL override (precedence: flag > share_server in cli-config.json > "+ - "$ARC_SHARE_SERVER > built-in default).") - shareCreateCmd.Flags().StringVar(&shareCreateAuthor, "author", "", - "Author name embedded in the plan (precedence: flag > share_author in "+ - "cli-config.json > $ARC_SHARE_AUTHOR > `git config user.name`). "+ - "Auto-populates the name chip when the Author URL is opened and is "+ - "used for display attribution. Author privileges are gated by the "+ - "&t= in the Author URL, not by this name.") - shareCreateCmd.Flags().StringVar(&shareCreateTitle, "title", "", - "Optional plan title shown in the share UI header (defaults to the filename)") - shareCommentsCmd.Flags().BoolVar(&shareCommentsAccepted, "accepted-only", false, "Only print accepted comments") - shareCommentsCmd.Flags().BoolVar(&shareCommentsJSON, "json", false, "Output as JSON") - shareShowCmd.Flags().BoolVar(&shareShowAuthorURL, "author-url", false, - "Print the author URL (includes edit_token) instead of the plan content") - shareDeleteCmd.Flags().BoolVarP(&shareDeleteForce, "force", "f", false, - "Remove the local registry entry even if the edit token is missing or the server delete fails") - - shareCmd.AddCommand(shareCreateCmd, shareListCmd, shareShowCmd, shareCommentsCmd, - sharePullCmd, shareApproveCmd, shareUpdateCmd, shareDeleteCmd) - rootCmd.AddCommand(shareCmd) -} - -// --- run* functions --- - -func runShareCreate(cmd *cobra.Command, args []string) error { - planFile := args[0] - md, err := os.ReadFile(planFile) - if err != nil { - return err - } - server, kind := resolveServer(shareCreateRemote, shareCreateServer) - key, err := paste.GenerateKey() - if err != nil { - return err - } - author := resolveAuthor(shareCreateAuthor) - if author == "" { - _, _ = fmt.Fprintln(os.Stderr, "warning: no author name resolved.") - _, _ = fmt.Fprintln(os.Stderr, " Set one via --author, share_author in ~/.arc/cli-config.json,") - _, _ = fmt.Fprintln(os.Stderr, " $ARC_SHARE_AUTHOR, or `git config user.name`.") - _, _ = fmt.Fprintln(os.Stderr, " Without an author, Accept/Resolve/Reject controls in the share UI") - _, _ = fmt.Fprintln(os.Stderr, " stay hidden for every reviewer.") - } - title := shareCreateTitle - if title == "" { - title = strings.TrimSuffix(filepath.Base(planFile), ".md") - } - plain := planPlaintext{ - Version: 1, - Markdown: string(md), - Title: title, - AuthorName: author, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } - blob, iv, err := paste.EncryptJSON(plain, key) - if err != nil { - return err - } - resp, err := postCreate(server, blob, iv) - if err != nil { - return err - } - keyB64 := base64.RawURLEncoding.EncodeToString(key) - if err := sharesconfig.Add(sharesconfig.Share{ - ID: resp.ID, - Kind: kind, - URL: server, - KeyB64Url: keyB64, - EditToken: resp.EditToken, - PlanFile: planFile, - CreatedAt: time.Now().UTC(), - }); err != nil { - return err - } - trimmedServer := strings.TrimRight(server, "/") - authorURL := fmt.Sprintf("%s/share/%s#k=%s&t=%s", trimmedServer, resp.ID, keyB64, resp.EditToken) - // Reviewer URL is intentionally NOT printed — copy-pasting the wrong line - // would hand a recipient author privileges (the &t= grants - // Accept/Resolve/Reject). Authors get a reviewer URL by opening the link - // below and clicking the in-page "Share link" button, which strips &t=. - if kind == shareKindLocal { - fmt.Printf("Preview URL (local-only — not reachable by others):\n %s\n\n", authorURL) - } else { - fmt.Printf("Author URL (keep private — open it, then use the in-page "+ - "Share link button to copy a reviewer URL):\n %s\n\n", authorURL) - } - fmt.Println("Edit token saved to the local arc keyring") - return nil -} - -// shareListEntry is the shape emitted by `arc share list --json`. -// Edit tokens are deliberately excluded — they're bearer secrets that -// belong in the keyring, not in machine-readable output. -type shareListEntry struct { - ID string `json:"id"` - Kind string `json:"kind"` - URL string `json:"url"` - KeyB64Url string `json:"key_b64url,omitempty"` - PlanFile string `json:"plan_file,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -func runShareList(cmd *cobra.Command, args []string) error { - f, err := sharesconfig.Load() - if err != nil { - return err - } - if outputJSON { - entries := make([]shareListEntry, 0, len(f.Shares)) - for _, s := range f.Shares { - entries = append(entries, shareListEntry{ - ID: s.ID, - Kind: s.Kind, - URL: s.URL, - KeyB64Url: s.KeyB64Url, - PlanFile: s.PlanFile, - CreatedAt: s.CreatedAt, - }) - } - outputResult(entries) - return nil - } - if len(f.Shares) == 0 { - fmt.Println("(no shares)") - return nil - } - for _, s := range f.Shares { - fmt.Printf("%s\t%s\t%s\t%s\n", s.ID, s.Kind, s.URL, s.PlanFile) - } - return nil -} - -func runShareShow(cmd *cobra.Command, args []string) error { - if shareShowAuthorURL { - return printAuthorURL(args[0]) - } - id, server, key, err := resolveShareRef(args[0]) - if err != nil { - return err - } - plan, _, err := fetchAndDecrypt(server, id, key) - if err != nil { - return err - } - fmt.Println(plan.Markdown) - return nil -} - -func printAuthorURL(ref string) error { - // Resolve to id; accept either bare id or full share URL. - id, _, _, err := resolveShareRef(ref) - if err != nil { - return err - } - s, _ := sharesconfig.Find(id) - if s == nil || s.EditToken == "" || s.KeyB64Url == "" { - return fmt.Errorf("no edit_token for share %s in the local arc keyring "+ - "(--author-url requires a share registered on this machine)", id) - } - fmt.Printf("%s/share/%s#k=%s&t=%s\n", - strings.TrimRight(s.URL, "/"), id, s.KeyB64Url, s.EditToken) - return nil -} - -func runShareComments(cmd *cobra.Command, args []string) error { - id, server, key, err := resolveShareRef(args[0]) - if err != nil { - return err - } - return printComments(server, id, key, shareCommentsAccepted, shareCommentsJSON) -} - -func runSharePull(cmd *cobra.Command, args []string) error { - id, server, key, err := resolveShareRef(args[0]) - if err != nil { - return err - } - return printComments(server, id, key, true, false) -} - -func runShareApprove(cmd *cobra.Command, args []string) error { - id, server, key, err := resolveShareRef(args[0]) - if err != nil { - return err - } - plan, _, err := fetchAndDecrypt(server, id, key) - if err != nil { - return err - } - ev := approvalEvent{ - Kind: "approval", - ID: fmt.Sprintf("a-%d", time.Now().UnixNano()), - AuthorName: plan.AuthorName, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } - blob, iv, err := paste.EncryptJSON(ev, key) - if err != nil { - return err - } - return postEvent(server, id, blob, iv) -} - -func runShareUpdate(cmd *cobra.Command, args []string) error { - ref, planFile := args[0], args[1] - md, err := os.ReadFile(planFile) - if err != nil { - return err - } - id, server, key, err := resolveShareRef(ref) - if err != nil { - return err - } - s, _ := sharesconfig.Find(id) - if s == nil || s.EditToken == "" { - return fmt.Errorf("no edit_token for share %s in the local arc keyring", id) - } - plain := planPlaintext{ - Version: 1, - Markdown: string(md), - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } - blob, iv, err := paste.EncryptJSON(plain, key) - if err != nil { - return err - } - return putPlan(server, id, s.EditToken, blob, iv) -} - -func runShareDelete(cmd *cobra.Command, args []string) error { - id, server, _, err := resolveShareRef(args[0]) - if err != nil { - return err - } - s, _ := sharesconfig.Find(id) - if s == nil || s.EditToken == "" { - if !shareDeleteForce { - return fmt.Errorf("no edit_token for share %s in the local arc keyring "+ - "(use --force to remove the local entry)", id) - } - _, _ = fmt.Fprintf(os.Stderr, "warning: no edit_token for %s; skipping server delete\n", id) - } else if err := deleteShare(server, id, s.EditToken); err != nil { - if !shareDeleteForce { - return err - } - _, _ = fmt.Fprintf(os.Stderr, "warning: server delete failed: %v; removing local entry anyway\n", err) - } - return sharesconfig.Remove(id) -} - -// --- helpers --- - -// resolveAuthor returns the author name to embed in the plan plaintext. -// Resolution order (highest priority first): -// 1. explicit --author flag -// 2. share_author in ~/.arc/cli-config.json -// 3. $ARC_SHARE_AUTHOR -// 4. `git config user.name` -// -// Returns "" if none of these produce a value. -// -// The author name is used for: (a) auto-populating the name chip when the -// Author URL is opened in the SPA, (b) display attribution on resolution and -// edit events, and (c) the replay-time guard in events.ts that drops forged -// edits/resolutions. Author UI privileges (Accept/Resolve/Reject) are gated -// by the &t= in the Author URL fragment, not by this name. -func resolveAuthor(flag string) string { - if s := strings.TrimSpace(flag); s != "" { - return s - } - if cfg, err := loadConfig(); err == nil { - if s := strings.TrimSpace(cfg.Share.Author); s != "" { - return s - } - } - if s := strings.TrimSpace(os.Getenv("ARC_SHARE_AUTHOR")); s != "" { - return s - } - out, err := exec.Command("git", "config", "user.name").Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -// resolveServer returns the server URL and kind ("local" or "shared") based -// on the provided flags. For shared (remote) mode, the URL is resolved with -// precedence: -// -// --server flag > share_server in ~/.arc/cli-config.json > $ARC_SHARE_SERVER > https://arcplanner.sentiolabs.io -// -// For local mode, the server URL comes from `server_url` in the CLI config -// (defaulting to http://localhost:7432). The `--server` flag still wins over -// everything in either mode — passing a URL there forces shared mode regardless -// of `--remote`. -func resolveServer(remote bool, override string) (server, kind string) { - if s := strings.TrimSpace(override); s != "" { - return s, shareKindShared - } - if remote { - return resolveShareServer(), shareKindShared - } - return cliConfigServerURL(), shareKindLocal -} - -// resolveShareServer returns the URL of the remote paste server, resolving in -// precedence order: config > env > built-in default. (The flag is checked one -// level up in resolveServer.) -func resolveShareServer() string { - if cfg, err := loadConfig(); err == nil { - // Only treat config as an override when the user has explicitly set a - // non-default server URL. If it's still the built-in default, fall - // through to the env-var and built-in tiers so the precedence chain - // cli-config > $ARC_SHARE_SERVER > built-in is preserved. - if s := strings.TrimSpace(cfg.Share.Server); s != "" && s != defaultShareServer { - return s - } - } - if s := strings.TrimSpace(os.Getenv("ARC_SHARE_SERVER")); s != "" { - return s - } - return defaultShareServer -} - -// cliConfigServerURL returns the server URL from the CLI config, falling back -// to the default local URL. -func cliConfigServerURL() string { - cfg, err := loadConfig() - if err != nil || cfg.CLI.Server == "" { - return "http://localhost:7432" - } - return cfg.CLI.Server -} - -// postCreate sends a CreatePasteRequest to the server and returns the response. -func postCreate(server string, blob, iv []byte) (*paste.CreatePasteResponse, error) { - u := strings.TrimRight(server, "/") + "/api/paste" - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: blob, PlanIV: iv, SchemaVer: 1}) - // Variable URL is the entire point of the CLI — the user picks the server. - //nolint:gosec // G107: intentional user-supplied server URL - resp, err := http.Post(u, "application/json", strings.NewReader(string(body))) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("create paste: %s: %s", resp.Status, b) - } - var out paste.CreatePasteResponse - return &out, json.NewDecoder(resp.Body).Decode(&out) -} - -// postEvent appends an encrypted event blob to an existing share. -func postEvent(server, id string, blob, iv []byte) error { - u := strings.TrimRight(server, "/") + "/api/paste/" + url.PathEscape(id) + "/blobs" - body, _ := json.Marshal(paste.AppendEventRequest{Blob: blob, IV: iv}) - // Variable URL is the entire point of the CLI — the user picks the server. - //nolint:gosec // G107: intentional user-supplied server URL - resp, err := http.Post(u, "application/json", strings.NewReader(string(body))) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("append event: %s: %s", resp.Status, b) - } - return nil -} - -// putPlan replaces the plan blob of an existing share using the edit token. -func putPlan(server, id, token string, blob, iv []byte) error { - u := strings.TrimRight(server, "/") + "/api/paste/" + url.PathEscape(id) - body, _ := json.Marshal(map[string][]byte{"plan_blob": blob, "plan_iv": iv}) - req, _ := http.NewRequest(http.MethodPut, u, strings.NewReader(string(body))) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("update plan: %s: %s", resp.Status, b) - } - return nil -} - -// deleteShare deletes a share using the edit token. -func deleteShare(server, id, token string) error { - u := strings.TrimRight(server, "/") + "/api/paste/" + url.PathEscape(id) - req, _ := http.NewRequest(http.MethodDelete, u, nil) - req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("delete share: %s: %s", resp.Status, b) - } - return nil -} - -// fetchAndDecrypt retrieves a share from the server and decrypts the plan -// blob using the provided key. -func fetchAndDecrypt(server, id string, key []byte) (*planPlaintext, []paste.Event, error) { - // Variable URL is the entire point of the CLI — the user picks the server. - getURL := strings.TrimRight(server, "/") + "/api/paste/" + url.PathEscape(id) - resp, err := http.Get(getURL) //nolint:gosec // G107: intentional user-supplied server URL - if err != nil { - return nil, nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, nil, fmt.Errorf("get paste: %s", resp.Status) - } - var pr struct { - paste.Share - Events []paste.Event `json:"events"` - } - if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { - return nil, nil, err - } - var plan planPlaintext - if err := paste.DecryptJSON(pr.PlanBlob, pr.PlanIV, key, &plan); err != nil { - return nil, nil, err - } - return &plan, pr.Events, nil -} - -// printComments fetches all events, decrypts them, and prints comments to -// stdout. When acceptedOnly is true only accepted comments are printed. When -// asJSON is true each comment is printed as a JSON object. -// -// Replay logic mirrors web/src/lib/paste/events.ts: -// - 'comment' events seed the state map. -// - 'resolution' events set the status, gated on author_name matching the -// plan's author (so reviewers can't self-accept). -// - 'edit' events merge body/suggested_text/comment_type onto the target -// comment, gated on author_name matching the comment's original author. -// -// Events are ordered by created_at (then by id as a deterministic tiebreaker) -// so the latest edit wins. -func printComments(server, id string, key []byte, acceptedOnly, asJSON bool) error { - plan, events, err := fetchAndDecrypt(server, id, key) - if err != nil { - return err - } - decoded := decodeAndSortEvents(events, key) - comments, resolutions, retracted := replayEvents(decoded, plan.AuthorName) - entries := buildCommentEntries(comments, resolutions, retracted, acceptedOnly) - if asJSON { - return emitBundle(id, plan, entries) - } - printCommentEntries(entries) - return nil -} - -// decodedEvent is the intermediate form used to sort events chronologically -// before replaying them. raw is kept around so the typed unmarshal can run in -// the replay loop without re-decrypting. -type decodedEvent struct { - kind string - raw json.RawMessage - ts string - eid string -} - -func decodeAndSortEvents(events []paste.Event, key []byte) []decodedEvent { - out := make([]decodedEvent, 0, len(events)) - for _, e := range events { - var raw json.RawMessage - if err := paste.DecryptJSON(e.Blob, e.IV, key, &raw); err != nil { - continue - } - var generic struct { - Kind string `json:"kind"` - ID string `json:"id"` - CreatedAt string `json:"created_at"` - } - if err := json.Unmarshal(raw, &generic); err != nil { - continue - } - out = append(out, decodedEvent{kind: generic.Kind, raw: raw, ts: generic.CreatedAt, eid: generic.ID}) - } - // Stable chronological order for deterministic edit application. - sort.SliceStable(out, func(i, j int) bool { - if out[i].ts != out[j].ts { - return out[i].ts < out[j].ts - } - return out[i].eid < out[j].eid - }) - return out -} - -func replayEvents( - events []decodedEvent, - planAuthor string, -) (map[string]commentEvent, map[string]resolutionEvent, map[string]bool) { - comments := map[string]commentEvent{} - resolutions := map[string]resolutionEvent{} - retracted := map[string]bool{} - for _, d := range events { - switch d.kind { - case "comment": - applyCommentEvent(d.raw, comments) - case "resolution": - applyResolutionEvent(d.raw, planAuthor, resolutions) - case "edit": - applyEditEvent(d.raw, planAuthor, comments) - case "retraction": - applyRetractionEvent(d.raw, comments, retracted) - } - } - return comments, resolutions, retracted -} - -// applyRetractionEvent marks the target comment as retracted iff the event's -// author_name matches the comment's original author_name. Plan author canNOT -// retract someone else's comment — they have Reject (with reply) for that -// purpose. Forged retractions are silently dropped, matching the edit-event -// authorization model. -func applyRetractionEvent(raw json.RawMessage, comments map[string]commentEvent, retracted map[string]bool) { - var r retractionEvent - if err := json.Unmarshal(raw, &r); err != nil { - return - } - target, ok := comments[r.CommentID] - if !ok { - return - } - if r.AuthorName != target.AuthorName { - return - } - retracted[r.CommentID] = true -} - -func applyCommentEvent(raw json.RawMessage, comments map[string]commentEvent) { - var c commentEvent - if err := json.Unmarshal(raw, &c); err == nil { - comments[c.ID] = c - } -} - -func applyResolutionEvent(raw json.RawMessage, planAuthor string, resolutions map[string]resolutionEvent) { - var r resolutionEvent - if err := json.Unmarshal(raw, &r); err != nil { - return - } - if planAuthor == "" || r.AuthorName == planAuthor { - resolutions[r.CommentID] = r - } -} - -// applyEditEvent merges body/suggested_text/comment_type fields onto the -// target comment. Replay-time auth: edits are accepted from either the -// comment's original author OR the plan author (so the author can sharpen -// thin reviewer feedback like "expand this more"). The displayed -// comment.author_name is unchanged either way — only the edit event itself -// records who actually edited. Forged events from third parties are silently -// dropped. planAuthor must be non-empty before granting plan-owner edit -// rights; otherwise empty strings on both sides would all match and -// accidentally authorize anonymous edits. -func applyEditEvent(raw json.RawMessage, planAuthor string, comments map[string]commentEvent) { - var ed editEvent - if err := json.Unmarshal(raw, &ed); err != nil { - return - } - c, ok := comments[ed.CommentID] - if !ok { - return - } - isOriginal := ed.AuthorName == c.AuthorName - isPlanAuthor := planAuthor != "" && ed.AuthorName == planAuthor - if !isOriginal && !isPlanAuthor { - return - } - if ed.Body != nil { - c.Body = *ed.Body - } - if ed.SuggestedText != nil { - c.SuggestedText = *ed.SuggestedText - } - if ed.CommentType != nil { - c.CommentType = *ed.CommentType - } - comments[ed.CommentID] = c -} - -func buildCommentEntries( - comments map[string]commentEvent, - resolutions map[string]resolutionEvent, - retracted map[string]bool, - acceptedOnly bool, -) []commentEntry { - // Build a deterministic-order list of comment entries so multiple runs - // produce identical output (Go map iteration is randomized). - entries := make([]commentEntry, 0, len(comments)) - for cid, c := range comments { - // Retracted comments are filtered from default output; the encrypted - // event still lives in the log for audit but downstream LLM consumers - // shouldn't see (and act on) revoked material. - if retracted[cid] { - continue - } - status := "open" - reply := "" - if res, ok := resolutions[cid]; ok { - status = res.Status - reply = res.Reply - } - if acceptedOnly && status != "accepted" { - continue - } - entries = append(entries, commentEntry{comment: c, status: status, reply: reply}) - } - sort.SliceStable(entries, func(i, j int) bool { - return entries[i].comment.CreatedAt < entries[j].comment.CreatedAt - }) - return entries -} - -func printCommentEntries(entries []commentEntry) { - for _, e := range entries { - // Mark deletes visually so they don't get mistaken for empty-body comments. - prefix := "" - if e.comment.Action == "delete" { - prefix = "[delete] " - } - fmt.Printf("[%s] %s%s (%s): %s\n", - e.status, prefix, e.comment.AuthorName, e.comment.CommentType, e.comment.Body) - } -} - -// shareBundle is the JSON shape emitted by `arc share comments --json`. -// -// Designed for LLM consumption: a single object with everything an agent -// needs to apply review feedback. -// -// Plan content is exposed via exactly one of two fields: -// - `file` — absolute path on disk. Set when the share is registered in -// the local arc keyring AND the file is readable. Agent reads it directly. -// - `markdown_b64` — base64-encoded markdown. Set when there's no local -// file (e.g. an agent consuming a shared URL it didn't create). Base64 -// avoids JSON escape bloat (every \n and \" doubles the byte count and -// destroys readability) for markdown payloads that can hit tens of KB. -// -// resolved_anchor line numbers are always computed against whichever source -// `file` or `markdown_b64` exposes, so they're ground-truth for the content -// the agent will actually see. -type shareBundle struct { - Plan bundlePlan `json:"plan"` - Comments []bundleComment `json:"comments"` -} - -type bundlePlan struct { - ID string `json:"id"` - Title string `json:"title,omitempty"` - AuthorName string `json:"author_name,omitempty"` - // File is the absolute path the agent should Edit. Present iff the - // share is in the local arc keyring and the file is readable. - File string `json:"file,omitempty"` - // MarkdownB64 is the plan content, base64-encoded. Present iff File - // is not set. Decode with standard base64 (RawStdEncoding-compatible). - MarkdownB64 string `json:"markdown_b64,omitempty"` -} - -type bundleComment struct { - Comment commentEvent `json:"comment"` - Status string `json:"status"` - Reply string `json:"reply,omitempty"` - ResolvedAnchor *bundleResolvedAnchor `json:"resolved_anchor,omitempty"` -} - -type bundleResolvedAnchor struct { - Status string `json:"status"` // "ok" | "drifted" | "orphaned" - LineStart int `json:"line_start"` - LineEnd int `json:"line_end"` - Snippet string `json:"snippet,omitempty"` -} - -// emitBundle assembles and prints the JSON bundle for `--json` output. -// -// Plan content sourcing: -// - If the share is in the local arc keyring AND the recorded file is readable, -// emit `file` (absolute path) and run anchor resolution against the -// file's current bytes. The agent reads the file directly. -// - Otherwise, emit `markdown_b64` containing the encrypted blob's -// markdown, base64-encoded to avoid JSON escape noise. -func emitBundle(id string, plan *planPlaintext, entries []commentEntry) error { - // `markdown` is what we resolve anchors against — the same bytes the - // agent will operate on, whether that's the local file or the shared - // blob. We then expose either `file` or `markdown_b64` to the agent - // based on what they have access to. - markdown := plan.Markdown - planFile := "" - if s, _ := sharesconfig.Find(id); s != nil && s.PlanFile != "" { - if data, err := os.ReadFile(s.PlanFile); err == nil { - markdown = string(data) - abs, absErr := filepath.Abs(s.PlanFile) - if absErr == nil { - planFile = abs - } else { - planFile = s.PlanFile - } - } - } - - bp := bundlePlan{ - ID: id, - Title: plan.Title, - AuthorName: plan.AuthorName, - } - if planFile != "" { - bp.File = planFile - } else { - bp.MarkdownB64 = base64.StdEncoding.EncodeToString([]byte(markdown)) - } - - bundle := shareBundle{ - Plan: bp, - Comments: make([]bundleComment, 0, len(entries)), - } - - for _, e := range entries { - bc := bundleComment{Comment: e.comment, Status: e.status, Reply: e.reply} - - // Re-encode the anchor (which arrived as `any`) and decode into the - // typed struct, so we can run resolution. If the anchor is malformed - // we leave resolved_anchor unset rather than fail the whole output. - if e.comment.Anchor != nil { - if raw, err := json.Marshal(e.comment.Anchor); err == nil { - var anc paste.Anchor - if json.Unmarshal(raw, &anc) == nil { - r := paste.ResolveAnchor(markdown, anc) - bc.ResolvedAnchor = &bundleResolvedAnchor{ - Status: r.Status, - LineStart: r.LineStart, - LineEnd: r.LineEnd, - Snippet: paste.Snippet(markdown, r), - } - } - } - } - bundle.Comments = append(bundle.Comments, bc) - } - - out, err := json.MarshalIndent(bundle, "", " ") - if err != nil { - return err - } - fmt.Println(string(out)) - return nil -} - -// resolveShareRef parses a share reference which may be either a full share -// URL (e.g. https://arcplanner.sentiolabs.io/share/abc12345#k=KEY) or a bare -// share ID known to the local arc keyring. -func resolveShareRef(ref string) (id, server string, key []byte, err error) { - if strings.Contains(ref, "://") { - return resolveShareURL(ref) - } - s, ferr := sharesconfig.Find(ref) - if ferr != nil { - if errors.Is(ferr, sharesconfig.ErrShareNotFound) { - return "", "", nil, fmt.Errorf("unknown share id: %s", ref) - } - return "", "", nil, ferr - } - key, _ = base64.RawURLEncoding.DecodeString(s.KeyB64Url) - return s.ID, s.URL, key, nil -} - -// resolveShareURL is the URL branch of resolveShareRef, split out to keep the -// nesting depth low. Falls back to the local arc keyring for the key if the -// URL has no fragment. -func resolveShareURL(ref string) (id, server string, key []byte, err error) { - u, perr := url.Parse(ref) - if perr != nil { - return "", "", nil, perr - } - parts := strings.Split(strings.Trim(u.Path, "/"), "/") - if len(parts) < 2 || parts[0] != "share" { - return "", "", nil, fmt.Errorf("invalid share URL: %s", ref) - } - id = parts[1] - frag, _ := url.ParseQuery(u.Fragment) - keyB64 := frag.Get("k") - if keyB64 == "" { - if s, ferr := sharesconfig.Find(id); ferr == nil { - keyB64 = s.KeyB64Url - } - } - key, derr := base64.RawURLEncoding.DecodeString(keyB64) - if derr != nil { - return "", "", nil, derr - } - return id, u.Scheme + "://" + u.Host, key, nil -} diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go deleted file mode 100644 index 8381e3f..0000000 --- a/cmd/arc/share_test.go +++ /dev/null @@ -1,1086 +0,0 @@ -package main - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/labstack/echo/v4" - _ "modernc.org/sqlite" - - "github.com/sentiolabs/arc/internal/api" - "github.com/sentiolabs/arc/internal/client" - "github.com/sentiolabs/arc/internal/paste" - pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" - "github.com/sentiolabs/arc/internal/sharesconfig" - "github.com/sentiolabs/arc/internal/storage/sqlite" -) - -func startTestPasteServer(t *testing.T) *httptest.Server { - t.Helper() - // Open an arc storage backed by a temp-file sqlite db so the full - // migration set (including 017_shares.sql) is applied. The paste - // subsystem migrations are run by sqlite.New itself, so the same db - // connection serves both /api/paste and /api/v1/shares. - dbPath := filepath.Join(t.TempDir(), "test.db") - store, err := sqlite.New(dbPath) - if err != nil { - t.Fatalf("sqlite.New: %v", err) - } - t.Cleanup(func() { _ = store.Close() }) - - e := echo.New() - paste.NewHandlers(pastesqlite.New(store.DB())).Register(e.Group("/api/paste")) - - // Mount the share keyring routes on /api/v1 against the same store so - // sharesconfig.{Load,Add,Find,Remove} hits a real handler chain. - apiSrv := api.New(api.ServerOptions{Store: store}) - apiSrv.RegisterShareRoutes(e.Group("/api/v1")) - - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - // Inject a client pointed at this test server so sharesconfig calls - // from CLI command code reach our in-process handlers. Restore the - // production factory on test exit so other tests in this package - // (and the CLI's main init) still see the real getClient wiring. - sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { - return client.New(srv.URL), nil - }) - t.Cleanup(func() { - sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { - return getClient() - }) - }) - - return srv -} - -func TestShareCreateRoundTrip(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - plan := filepath.Join(t.TempDir(), "plan.md") - _ = os.WriteFile(plan, []byte("# Hello\n\nBody."), 0o600) - - shareCreateServer = srv.URL - if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil { - t.Fatalf("runShareCreate: %v", err) - } - - f, _ := sharesconfig.Load() - if len(f.Shares) != 1 { - t.Fatalf("expected 1 share recorded, got %d", len(f.Shares)) - } - s := f.Shares[0] - if s.URL != srv.URL { - t.Errorf("URL mismatch: %s vs %s", s.URL, srv.URL) - } - if s.EditToken == "" || s.KeyB64Url == "" { - t.Errorf("missing edit_token or key: %+v", s) - } -} - -func TestResolveShareRefFromURL(t *testing.T) { - id, server, key, err := resolveShareRef("https://arcplanner.sentiolabs.io/share/abc12345#k=AAAA") - if err != nil { - t.Fatal(err) - } - if id != "abc12345" || server != "https://arcplanner.sentiolabs.io" || len(key) == 0 { - t.Errorf("bad parse: id=%s server=%s key=%v", id, server, key) - } -} - -func TestRunShareCommentsRoundTrip(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - // Create a plan - plan := filepath.Join(t.TempDir(), "p.md") - _ = os.WriteFile(plan, []byte("# P"), 0o600) - shareCreateServer = srv.URL - _ = runShareCreate(shareCreateCmd, []string{plan}) - - f, _ := sharesconfig.Load() - s := f.Shares[0] - keyBytes := mustDecodeKey(t, s.KeyB64Url) - - // Manually post a comment event. - c := map[string]any{ - "kind": "comment", "id": "c1", "author_name": "Alice", "comment_type": "comment", - "body": "looks good", "anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "P"}, - "created_at": "2026-04-29T00:00:00Z", - } - blob, iv, _ := paste.EncryptJSON(c, keyBytes) - body, _ := json.Marshal(paste.AppendEventRequest{Blob: blob, IV: iv}) - if err := postRaw(t, srv.URL+"/api/paste/"+s.ID+"/blobs", body); err != nil { - t.Fatal(err) - } - - // Capture stdout while running comments - out := captureStdout(t, func() { _ = runShareComments(shareCommentsCmd, []string{s.ID}) }) - if !strings.Contains(out, "Alice") || !strings.Contains(out, "looks good") { - t.Errorf("expected Alice/looks good in output, got: %s", out) - } -} - -// TestRunShareCommentsHidesRetracted verifies that a retraction event from the -// comment's original author removes the comment from `arc share comments` -// default output, while a forged retraction (mismatched author_name) is -// silently dropped at replay time. The encrypted retraction event remains in -// the log for audit. -func TestRunShareCommentsHidesRetracted(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - plan := filepath.Join(t.TempDir(), "p.md") - _ = os.WriteFile(plan, []byte("# P"), 0o600) - shareCreateServer = srv.URL - if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil { - t.Fatal(err) - } - f, _ := sharesconfig.Load() - s := f.Shares[0] - keyBytes := mustDecodeKey(t, s.KeyB64Url) - - postEv := func(t *testing.T, payload map[string]any) { - t.Helper() - blob, iv, err := paste.EncryptJSON(payload, keyBytes) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - body, _ := json.Marshal(paste.AppendEventRequest{Blob: blob, IV: iv}) - if err := postRaw(t, srv.URL+"/api/paste/"+s.ID+"/blobs", body); err != nil { - t.Fatal(err) - } - } - - // Two comments: Alice's (which she'll retract) and Bob's (which she can't). - postEv(t, map[string]any{ - "kind": "comment", "id": "c1", "author_name": "Alice", "comment_type": "comment", - "body": "regret posting this", - "anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "P"}, - "created_at": "2026-04-29T00:00:00Z", - }) - postEv(t, map[string]any{ - "kind": "comment", "id": "c2", "author_name": "Bob", "comment_type": "comment", - "body": "stays visible", - "anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "P"}, - "created_at": "2026-04-29T00:01:00Z", - }) - - // Alice retracts her own — must take effect. - postEv(t, map[string]any{ - "kind": "retraction", "id": "x1", "comment_id": "c1", "author_name": "Alice", - "created_at": "2026-04-29T00:02:00Z", - }) - // Mallory tries to retract Bob's — must be silently dropped. - postEv(t, map[string]any{ - "kind": "retraction", "id": "x2", "comment_id": "c2", "author_name": "Mallory", - "created_at": "2026-04-29T00:03:00Z", - }) - - out := captureStdout(t, func() { _ = runShareComments(shareCommentsCmd, []string{s.ID}) }) - - if strings.Contains(out, "regret posting this") { - t.Errorf("retracted comment must be hidden; got:\n%s", out) - } - if !strings.Contains(out, "stays visible") { - t.Errorf("forged retraction should not have removed Bob's comment; got:\n%s", out) - } - if !strings.Contains(out, "Bob") { - t.Errorf("expected Bob's comment to still be present; got:\n%s", out) - } -} - -// TestRunShareCommentsAppliesEdits verifies that an `edit` event from the -// comment's original author rewrites the body shown by `arc share comments`. -// This locks in CLI parity with the SPA's replay logic. -func TestRunShareCommentsAppliesEdits(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - plan := filepath.Join(t.TempDir(), "p.md") - _ = os.WriteFile(plan, []byte("# P"), 0o600) - shareCreateServer = srv.URL - _ = runShareCreate(shareCreateCmd, []string{plan}) - - f, _ := sharesconfig.Load() - s := f.Shares[0] - keyBytes := mustDecodeKey(t, s.KeyB64Url) - - postEv := func(t *testing.T, payload map[string]any) { - t.Helper() - blob, iv, err := paste.EncryptJSON(payload, keyBytes) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - body, _ := json.Marshal(paste.AppendEventRequest{Blob: blob, IV: iv}) - if err := postRaw(t, srv.URL+"/api/paste/"+s.ID+"/blobs", body); err != nil { - t.Fatal(err) - } - } - - // 1) Steve posts a thin "expand this more" comment. - postEv(t, map[string]any{ - "kind": "comment", "id": "c1", "author_name": "Steve", "comment_type": "comment", - "body": "expand this more", - "anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "P"}, - "created_at": "2026-04-29T00:00:00Z", - }) - - // 2) Steve revises it with a fully-formed thought. - postEv(t, map[string]any{ - "kind": "edit", "id": "e1", "comment_id": "c1", "author_name": "Steve", - "body": "the goal section should mention the success criteria for ‘validated’", - "created_at": "2026-04-29T00:05:00Z", - }) - - // 3) Mallory tries to forge an edit pretending to be Steve. (Wrong author_name - // on the edit event; replay must drop it.) - postEv(t, map[string]any{ - "kind": "edit", "id": "e2", "comment_id": "c1", "author_name": "Mallory", - "body": "MALICIOUS REWRITE", - "created_at": "2026-04-29T00:06:00Z", - }) - - out := captureStdout(t, func() { _ = runShareComments(shareCommentsCmd, []string{s.ID}) }) - - if !strings.Contains(out, "success criteria") { - t.Errorf("expected edited body in output; got:\n%s", out) - } - if strings.Contains(out, "expand this more") { - t.Errorf("expected stale body to be replaced; got:\n%s", out) - } - if strings.Contains(out, "MALICIOUS REWRITE") { - t.Errorf("forged edit must be ignored at replay time; got:\n%s", out) - } -} - -// TestRunShareCommentsAppliesPlanAuthorEdits verifies that the plan author -// can edit any reviewer's comment, mirroring the SPA's replay rule. This is -// the "Ben sharpens Steve's 'expand this more' into something useful" -// workflow — comment.author_name stays as the original reviewer. -func TestRunShareCommentsAppliesPlanAuthorEdits(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - plan := filepath.Join(t.TempDir(), "p.md") - _ = os.WriteFile(plan, []byte("# P"), 0o600) - shareCreateServer = srv.URL - // Pin the plan author via flag so the replay knows who has author rights. - shareCreateAuthor = "Ben" - t.Cleanup(func() { shareCreateAuthor = "" }) - if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil { - t.Fatal(err) - } - f, _ := sharesconfig.Load() - s := f.Shares[0] - keyBytes := mustDecodeKey(t, s.KeyB64Url) - - postEv := func(t *testing.T, payload map[string]any) { - t.Helper() - blob, iv, err := paste.EncryptJSON(payload, keyBytes) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - body, _ := json.Marshal(paste.AppendEventRequest{Blob: blob, IV: iv}) - if err := postRaw(t, srv.URL+"/api/paste/"+s.ID+"/blobs", body); err != nil { - t.Fatal(err) - } - } - - // Steve leaves a thin comment, then Ben (plan author) refines the body. - postEv(t, map[string]any{ - "kind": "comment", "id": "c1", "author_name": "Steve", "comment_type": "comment", - "body": "expand this more", - "anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "P"}, - "created_at": "2026-04-29T00:00:00Z", - }) - postEv(t, map[string]any{ - "kind": "edit", "id": "e1", "comment_id": "c1", "author_name": "Ben", - "body": "the goal section needs explicit success criteria for 'validated'", - "created_at": "2026-04-29T00:05:00Z", - }) - // Mallory tries to edit too — must be ignored. - postEv(t, map[string]any{ - "kind": "edit", "id": "e2", "comment_id": "c1", "author_name": "Mallory", - "body": "MALICIOUS", - "created_at": "2026-04-29T00:06:00Z", - }) - - out := captureStdout(t, func() { _ = runShareComments(shareCommentsCmd, []string{s.ID}) }) - - if !strings.Contains(out, "explicit success criteria") { - t.Errorf("expected plan author's edited body in output; got:\n%s", out) - } - if strings.Contains(out, "expand this more") { - t.Errorf("expected stale body to be replaced; got:\n%s", out) - } - // Author attribution unchanged: the line should still be tagged with Steve. - if !strings.Contains(out, "Steve") { - t.Errorf("comment.author_name should still be Steve in the output; got:\n%s", out) - } - if strings.Contains(out, "MALICIOUS") { - t.Errorf("third-party edit must be ignored; got:\n%s", out) - } -} - -// TestRunShareCommentsJSONBundle verifies the shape of `--json` output: -// a single JSON object containing plan metadata + comments with action -// preserved + resolved_anchor populated against the on-disk plan file. -// -// This is the contract that `arc share comments --json` exposes to the -// brainstorm skill / LLM agents — locking it in here so changes that -// would break agent consumption fail the test. -func TestRunShareCommentsJSONBundle(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - // A small but realistic plan with sections the SPA would slugify. - planText := "# Test Plan\n\n## Goal\n\nValidate the shared review feature.\n\n" + - "## Approach\n\n- Selection-based annotation\n- Conventional labels\n" - planFile := filepath.Join(t.TempDir(), "plan.md") - if err := os.WriteFile(planFile, []byte(planText), 0o600); err != nil { - t.Fatal(err) - } - shareCreateServer = srv.URL - if err := runShareCreate(shareCreateCmd, []string{planFile}); err != nil { - t.Fatal(err) - } - f, _ := sharesconfig.Load() - s := f.Shares[0] - keyBytes := mustDecodeKey(t, s.KeyB64Url) - - postEv := func(t *testing.T, payload map[string]any) { - t.Helper() - blob, iv, err := paste.EncryptJSON(payload, keyBytes) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - body, _ := json.Marshal(paste.AppendEventRequest{Blob: blob, IV: iv}) - if err := postRaw(t, srv.URL+"/api/paste/"+s.ID+"/blobs", body); err != nil { - t.Fatal(err) - } - } - - // One regular comment + one delete annotation. The delete tests that - // `action` round-trips to the JSON output (was the bug — Go side used - // to silently drop it). - postEv(t, map[string]any{ - "kind": "comment", - "id": "c1", - "author_name": "Steve", - "comment_type": "issue", - "body": "Goal section should mention success criteria", - "anchor": map[string]any{ - "line_start": 5, - "line_end": 5, - "quoted_text": "Validate the shared review feature.", - "heading_slug": "goal", - }, - "created_at": "2026-04-29T00:00:00Z", - }) - postEv(t, map[string]any{ - "kind": "comment", - "id": "c2", - "author_name": "Mike", - "comment_type": "comment", - "action": "delete", - "body": "", - "anchor": map[string]any{ - "line_start": 9, - "line_end": 9, - "quoted_text": "Conventional labels", - "heading_slug": "approach", - }, - "created_at": "2026-04-29T00:01:00Z", - }) - - // Run with --json - shareCommentsJSON = true - t.Cleanup(func() { shareCommentsJSON = false }) - out := captureStdout(t, func() { _ = runShareComments(shareCommentsCmd, []string{s.ID}) }) - - var bundle struct { - Plan struct { - ID string `json:"id"` - Title string `json:"title"` - File string `json:"file"` - MarkdownB64 string `json:"markdown_b64"` - } `json:"plan"` - Comments []struct { - Comment struct { - ID string `json:"id"` - Action string `json:"action"` - AuthorName string `json:"author_name"` - Body string `json:"body"` - } `json:"comment"` - Status string `json:"status"` - ResolvedAnchor *struct { - Status string `json:"status"` - LineStart int `json:"line_start"` - LineEnd int `json:"line_end"` - Snippet string `json:"snippet"` - } `json:"resolved_anchor"` - } `json:"comments"` - } - if err := json.Unmarshal([]byte(out), &bundle); err != nil { - t.Fatalf("output is not valid JSON bundle: %v\noutput:\n%s", err, out) - } - - // --- Plan section --- - if bundle.Plan.ID != s.ID { - t.Errorf("plan.id = %q, want %q", bundle.Plan.ID, s.ID) - } - // File-readable case: `file` is set, `markdown_b64` MUST be omitted. - // The agent reads the file directly — no need to ship its bytes twice. - if !strings.HasSuffix(bundle.Plan.File, "plan.md") { - t.Errorf("plan.file should be absolute path ending in plan.md; got %q", bundle.Plan.File) - } - if !filepath.IsAbs(bundle.Plan.File) { - t.Errorf("plan.file should be absolute; got %q", bundle.Plan.File) - } - if bundle.Plan.MarkdownB64 != "" { - t.Errorf("plan.markdown_b64 must be empty when plan.file is set; got %d bytes", len(bundle.Plan.MarkdownB64)) - } - - // --- Comments section --- - if len(bundle.Comments) != 2 { - t.Fatalf("expected 2 comments, got %d", len(bundle.Comments)) - } - // Sorted by created_at, so c1 comes before c2. - if bundle.Comments[0].Comment.ID != "c1" || bundle.Comments[1].Comment.ID != "c2" { - t.Errorf("comments not in chronological order: got %s, %s", - bundle.Comments[0].Comment.ID, bundle.Comments[1].Comment.ID) - } - if bundle.Comments[1].Comment.Action != "delete" { - t.Errorf("action field dropped on delete comment; got %q, want \"delete\"", - bundle.Comments[1].Comment.Action) - } - - // --- Resolved anchor --- - r0 := bundle.Comments[0].ResolvedAnchor - if r0 == nil { - t.Fatal("resolved_anchor missing on c1") - } - if r0.Status != "ok" { - t.Errorf("c1 anchor status = %q, want ok (line numbers should match)", r0.Status) - } - if r0.Snippet == "" { - t.Errorf("expected snippet for resolved anchor; got empty") - } - if !strings.Contains(r0.Snippet, "Validate the shared review") { - t.Errorf("snippet should include the quoted text; got %q", r0.Snippet) - } -} - -// TestRunShareCommentsJSONBundle_NoLocalFile covers the "agent on a -// different machine" case: the share isn't in this machine's shares.json -// (or the recorded file is unreadable), so the bundle must include the -// markdown as base64 instead of a file path. -func TestRunShareCommentsJSONBundle_NoLocalFile(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - // Create a share, then DELETE the registry entry to simulate "this - // machine doesn't know about this share." The encrypted blob still - // has the plan content, so the CLI falls back to it. - planText := "# Test Plan\n\n## Goal\n\nA quick \"quoted\" test with newlines.\n" - planFile := filepath.Join(t.TempDir(), "plan.md") - if err := os.WriteFile(planFile, []byte(planText), 0o600); err != nil { - t.Fatal(err) - } - shareCreateServer = srv.URL - if err := runShareCreate(shareCreateCmd, []string{planFile}); err != nil { - t.Fatal(err) - } - f, _ := sharesconfig.Load() - s := f.Shares[0] - keyBytes := mustDecodeKey(t, s.KeyB64Url) - - // Wipe shares.json so the lookup fails — same effect as fetching a - // share you didn't create on this machine. - if err := sharesconfig.Remove(s.ID); err != nil { - t.Fatal(err) - } - - // Also need to pass the full URL since the bare ID won't resolve now. - url := srv.URL + "/share/" + s.ID + "#k=" + s.KeyB64Url - _ = keyBytes - - shareCommentsJSON = true - t.Cleanup(func() { shareCommentsJSON = false }) - out := captureStdout(t, func() { _ = runShareComments(shareCommentsCmd, []string{url}) }) - - var bundle struct { - Plan struct { - File string `json:"file"` - MarkdownB64 string `json:"markdown_b64"` - } `json:"plan"` - } - if err := json.Unmarshal([]byte(out), &bundle); err != nil { - t.Fatalf("not valid JSON: %v\n%s", err, out) - } - if bundle.Plan.File != "" { - t.Errorf("plan.file should be empty when share is not registered; got %q", bundle.Plan.File) - } - if bundle.Plan.MarkdownB64 == "" { - t.Fatal("plan.markdown_b64 must be set when plan.file is empty") - } - decoded, err := base64.StdEncoding.DecodeString(bundle.Plan.MarkdownB64) - if err != nil { - t.Fatalf("markdown_b64 not valid base64: %v", err) - } - if string(decoded) != planText { - t.Errorf("decoded markdown_b64 doesn't match original.\n got: %q\n want: %q", - string(decoded), planText) - } -} - -// mustDecodeKey decodes a base64url key or fatals the test. -func mustDecodeKey(t *testing.T, b64 string) []byte { - t.Helper() - key, err := base64.RawURLEncoding.DecodeString(b64) - if err != nil { - t.Fatalf("decode key: %v", err) - } - return key -} - -// postRaw sends an HTTP POST with a JSON body to url and fails if the status -// is not 2xx. -func postRaw(t *testing.T, url string, body []byte) error { - t.Helper() - // Variable URL is intentional — tests post to httptest.Server URLs. - resp, err := http.Post(url, "application/json", bytes.NewReader(body)) //nolint:gosec // G107: test-controlled URL - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - t.Errorf("postRaw %s: %s: %s", url, resp.Status, b) - } - return nil -} - -// captureStdout captures writes to os.Stdout during fn and returns the -// captured output as a string. -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - old := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("pipe: %v", err) - } - os.Stdout = w - - fn() - - _ = w.Close() - os.Stdout = old - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - return buf.String() -} - -// TestResolveAuthor locks in the resolution precedence: -// -// flag > config file > env var > git config (lowest) -// -// We isolate from the user's real ~/.arc/cli-config.json by pointing the -// global `configPath` at a temp file, and from the user's git identity by -// running each subtest with $PATH cleared so `git` is unavailable (the -// helper falls back silently to "" on git failure). -// In shared (remote) mode, `arc share create` prints exactly one URL — the -// Author URL with &t= — labeled with privacy guidance and a pointer at the -// in-page Share-link button. The standalone reviewer URL line is gone: -// printing it was a footgun (copy-pasting the wrong line gave recipients -// author privileges via the &t= token). -func TestShareCreatePrintsAuthorURLOnly(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - plan := filepath.Join(t.TempDir(), "plan.md") - _ = os.WriteFile(plan, []byte("# Hello\n\nBody."), 0o600) - - r, w, _ := os.Pipe() - stdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = stdout }() - - shareCreateServer = srv.URL // forces shared mode regardless of --remote - t.Cleanup(func() { shareCreateServer = "" }) - if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil { - t.Fatalf("runShareCreate: %v", err) - } - _ = w.Close() - out, _ := io.ReadAll(r) - output := string(out) - - if !strings.Contains(output, "Author URL") { - t.Errorf("expected 'Author URL' label, got: %s", output) - } - if !strings.Contains(output, "keep private") { - t.Errorf("expected privacy guidance for author URL, got: %s", output) - } - if !strings.Contains(output, "Share link button") { - t.Errorf("expected pointer at the in-page Share-link button, got: %s", output) - } - // Reviewer-URL artifacts from the old dual-print MUST be gone. - if strings.Contains(output, "Share URL") { - t.Errorf("'Share URL' label leaked back into output: %s", output) - } - if strings.Contains(output, "send to reviewers") { - t.Errorf("'send to reviewers' guidance leaked back into output: %s", output) - } - if strings.Contains(output, "Preview URL") { - t.Errorf("'Preview URL' label appeared in shared-mode output: %s", output) - } - if strings.Contains(output, "Edit token: ") { - t.Errorf("raw 'Edit token: ' line should not appear in output: %s", output) - } - if !strings.Contains(output, "Edit token saved to") { - t.Errorf("expected pointer to shares.json, got: %s", output) - } - - // Exactly one /share/ line should appear, and it must carry &t=. - shareLines := 0 - hasToken := false - for line := range strings.SplitSeq(output, "\n") { - if strings.Contains(line, "/share/") { - shareLines++ - if strings.Contains(line, "&t=") { - hasToken = true - } - } - } - if shareLines != 1 { - t.Errorf("expected exactly one /share/ line, got %d. output: %s", shareLines, output) - } - if !hasToken { - t.Errorf("expected the single share line to carry &t=, got: %s", output) - } -} - -// In local (default) mode, `arc share create` labels the URL "Preview URL" -// and notes it's local-only — printing a "Share URL" / "Author URL" label -// would suggest the link is shareable, but a localhost URL can't be opened -// by anyone else. -func TestShareCreatePrintsPreviewURLForLocal(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - // Point the local CLI config at the test server so cliConfigServerURL() - // returns srv.URL during this test. The shared/remote escape hatches - // (--server flag, --remote) are deliberately left clear. - origConfigPath := configPath - t.Cleanup(func() { configPath = origConfigPath }) - dir := t.TempDir() - configPath = filepath.Join(dir, "config.toml") - tomlBody := "[cli]\nserver = \"" + srv.URL + "\"\n" + - "[share]\n[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" - if err := os.WriteFile(configPath, []byte(tomlBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } - - plan := filepath.Join(t.TempDir(), "plan.md") - _ = os.WriteFile(plan, []byte("# Hello\n\nBody."), 0o600) - - r, w, _ := os.Pipe() - stdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = stdout }() - - // shareCreateRemote is false and shareCreateServer is empty → local mode. - shareCreateRemote = false - shareCreateServer = "" - if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil { - t.Fatalf("runShareCreate: %v", err) - } - _ = w.Close() - out, _ := io.ReadAll(r) - output := string(out) - - if !strings.Contains(output, "Preview URL") { - t.Errorf("expected 'Preview URL' label, got: %s", output) - } - if !strings.Contains(output, "local-only") { - t.Errorf("expected 'local-only' guidance, got: %s", output) - } - if strings.Contains(output, "Author URL") { - t.Errorf("'Author URL' label should NOT appear for local shares, got: %s", output) - } - if strings.Contains(output, "Share URL") { - t.Errorf("'Share URL' label should NOT appear, got: %s", output) - } - - // Even for local, the printed URL is the author-token URL — it's the - // preview the author opens themselves. Just confirm exactly one /share/ - // line and that it carries &t=. - shareLines := 0 - hasToken := false - for line := range strings.SplitSeq(output, "\n") { - if strings.Contains(line, "/share/") { - shareLines++ - if strings.Contains(line, "&t=") { - hasToken = true - } - } - } - if shareLines != 1 { - t.Errorf("expected exactly one /share/ line, got %d. output: %s", shareLines, output) - } - if !hasToken { - t.Errorf("expected the preview URL line to carry &t=, got: %s", output) - } - // Verify the URL uses the configured test server — not the fallback - // localhost:7432. This catches a stale/invalid config fixture that causes - // loadConfig to fail and silently fall back to defaults. - if !strings.Contains(output, srv.URL) { - t.Errorf("expected output to contain configured server URL %s, got: %s", srv.URL, output) - } -} - -func TestResolveAuthor(t *testing.T) { - // Save & restore globals touched by the helper. Env vars use t.Setenv - // which auto-restores; configPath is package-global so we restore manually. - origConfigPath := configPath - t.Cleanup(func() { configPath = origConfigPath }) - - // Strip git from PATH so the lowest tier resolves to "" deterministically. - t.Setenv("PATH", "") - - writeConfig := func(t *testing.T, author string) { - t.Helper() - dir := t.TempDir() - configPath = filepath.Join(dir, "config.toml") - body := "[cli]\nserver = \"http://localhost:7432\"\n[share]\n" - if author != "" { - body += "author = \"" + author + "\"\n" - } - body += "[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" - if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } - } - - t.Run("flag wins over everything", func(t *testing.T) { - writeConfig(t, "from-config") - t.Setenv("ARC_SHARE_AUTHOR", "from-env") - if got := resolveAuthor("from-flag"); got != "from-flag" { - t.Errorf("flag should win; got %q", got) - } - }) - - t.Run("config wins over env when no flag", func(t *testing.T) { - writeConfig(t, "from-config") - t.Setenv("ARC_SHARE_AUTHOR", "from-env") - if got := resolveAuthor(""); got != "from-config" { - t.Errorf("config should win over env; got %q", got) - } - }) - - t.Run("env wins when config empty", func(t *testing.T) { - writeConfig(t, "") - t.Setenv("ARC_SHARE_AUTHOR", "from-env") - if got := resolveAuthor(""); got != "from-env" { - t.Errorf("env should win; got %q", got) - } - }) - - t.Run("flag whitespace is trimmed and treated as empty", func(t *testing.T) { - writeConfig(t, "from-config") - t.Setenv("ARC_SHARE_AUTHOR", "") - if got := resolveAuthor(" "); got != "from-config" { - t.Errorf("whitespace flag should fall through; got %q", got) - } - }) - - t.Run("everything empty resolves to empty string", func(t *testing.T) { - writeConfig(t, "") - t.Setenv("ARC_SHARE_AUTHOR", "") - if got := resolveAuthor(""); got != "" { - t.Errorf("expected empty; got %q", got) - } - }) -} - -// TestResolveServer locks in the share-server precedence: -// -// --server flag > share_server in ~/.arc/cli-config.json > $ARC_SHARE_SERVER > built-in default -// -// We isolate from the user's real cli-config.json by pointing the global -// `configPath` at a temp file. The `_, kind` return is asserted as -// "shared" everywhere a flag/env/config resolution is expected, since -// shared mode is the only branch that consults these sources. -func TestResolveServer(t *testing.T) { - const builtinDefault = "https://arcplanner.sentiolabs.io" - - // Save & restore configPath manually; ARC_SHARE_SERVER uses t.Setenv - // inside subtests for auto-restore. - origConfigPath := configPath - t.Cleanup(func() { configPath = origConfigPath }) - - cases := []struct { - name string - config string // share_server in cli-config.json; "" omits the field - env string // ARC_SHARE_SERVER value; "" clears it - remote bool - flag string - wantURL string - wantKind string - wantNoEnv bool // true → don't t.Setenv at all (skip env priming) - }{ - { - name: "flag wins over everything", - config: "https://from-config.example", env: "https://from-env.example", - remote: true, flag: "https://from-flag.example", - wantURL: "https://from-flag.example", wantKind: shareKindShared, - }, - { - // The override flag is intentionally global — it forces shared mode - // even without --remote, mirroring the previous behavior. Locked in - // here so it doesn't silently drift. - name: "flag wins even without --remote", - config: "https://from-config.example", - flag: "https://from-flag.example", wantNoEnv: true, - wantURL: "https://from-flag.example", wantKind: shareKindShared, - }, - { - name: "config wins over env when no flag", - config: "https://from-config.example", env: "https://from-env.example", - remote: true, - wantURL: "https://from-config.example", wantKind: shareKindShared, - }, - { - name: "env wins when config empty", - env: "https://from-env.example", remote: true, - wantURL: "https://from-env.example", wantKind: shareKindShared, - }, - { - name: "falls back to built-in default", - remote: true, - wantURL: builtinDefault, wantKind: shareKindShared, - }, - { - name: "flag whitespace is trimmed and treated as empty", - config: "https://from-config.example", - remote: true, flag: " ", - wantURL: "https://from-config.example", wantKind: shareKindShared, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - writeShareServerConfig(t, tc.config) - if !tc.wantNoEnv { - t.Setenv("ARC_SHARE_SERVER", tc.env) - } - got, kind := resolveServer(tc.remote, tc.flag) - if got != tc.wantURL || kind != tc.wantKind { - t.Errorf("resolveServer = (%q, %q), want (%q, %q)", got, kind, tc.wantURL, tc.wantKind) - } - }) - } -} - -func TestShareShowAuthorURL(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - // Create a share so shares.json has an entry. - plan := filepath.Join(t.TempDir(), "plan.md") - _ = os.WriteFile(plan, []byte("# Hi"), 0o600) - shareCreateServer = srv.URL - if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil { - t.Fatal(err) - } - f, _ := sharesconfig.Load() - id := f.Shares[0].ID - editToken := f.Shares[0].EditToken - keyB64 := f.Shares[0].KeyB64Url - - // Capture stdout for `arc share show --author-url`. - r, w, _ := os.Pipe() - stdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = stdout }() - - shareShowAuthorURL = true - defer func() { shareShowAuthorURL = false }() - if err := runShareShow(shareShowCmd, []string{id}); err != nil { - t.Fatalf("runShareShow: %v", err) - } - _ = w.Close() - out, _ := io.ReadAll(r) - got := strings.TrimSpace(string(out)) - - want := srv.URL + "/share/" + id + "#k=" + keyB64 + "&t=" + editToken - if got != want { - t.Errorf("author URL mismatch:\n got: %q\nwant: %q", got, want) - } -} - -func TestShareShowAuthorURLMissingShare(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - // Bring up an empty in-process server so sharesconfig.Find returns - // a clean ErrShareNotFound rather than trying to dial localhost:7432. - _ = startTestPasteServer(t) - - shareShowAuthorURL = true - defer func() { shareShowAuthorURL = false }() - err := runShareShow(shareShowCmd, []string{"abc12345"}) - if err == nil { - t.Fatal("expected error for missing share, got nil") - } - if !strings.Contains(err.Error(), "abc12345") { - t.Errorf("error should reference share id, got: %v", err) - } -} - -// writeShareServerConfig points the global configPath at a temp config.toml -// containing the given share.server (omitted/default when empty). -func writeShareServerConfig(t *testing.T, server string) { - t.Helper() - dir := t.TempDir() - configPath = filepath.Join(dir, "config.toml") - body := "[cli]\nserver = \"http://localhost:7432\"\n[share]\n" - if server != "" { - body += "server = \"" + server + "\"\n" - } else { - body += "server = \"https://arcplanner.sentiolabs.io\"\n" - } - body += "[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" - if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } -} - -// TestRunShareListJSON locks in `arc share list --json` emitting a JSON -// array of share entries (vs the default tab-separated text). Regression -// guard: this command silently ignored the global --json flag prior to -// this test landing. -func TestRunShareListJSON(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - // Seed two shares via the keyring so the list output has multiple rows. - must := func(err error) { - t.Helper() - if err != nil { - t.Fatalf("seed share: %v", err) - } - } - must(sharesconfig.Add(sharesconfig.Share{ - ID: "abc12345", - Kind: "local", - URL: "http://localhost:7432/share/abc12345#k=key1", - KeyB64Url: "key1", - EditToken: "tok1", - PlanFile: "docs/plans/a.md", - })) - must(sharesconfig.Add(sharesconfig.Share{ - ID: "def67890", - Kind: "shared", - URL: "https://arcplanner.sentiolabs.io/share/def67890#k=key2", - KeyB64Url: "key2", - EditToken: "tok2", - PlanFile: "docs/plans/b.md", - })) - - // Force the global --json flag on for this test, restore after. - prev := outputJSON - outputJSON = true - t.Cleanup(func() { outputJSON = prev }) - - out := captureStdout(t, func() { - if err := runShareList(shareListCmd, nil); err != nil { - t.Fatalf("runShareList: %v", err) - } - }) - - // Output must parse as a JSON array (not the legacy tab-separated text). - var got []sharesconfig.Share - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("output is not valid JSON array: %v\noutput: %q", err, out) - } - if len(got) != 2 { - t.Fatalf("expected 2 shares, got %d: %+v", len(got), got) - } - ids := map[string]bool{got[0].ID: true, got[1].ID: true} - if !ids["abc12345"] || !ids["def67890"] { - t.Errorf("expected both seeded share IDs in output, got: %+v", got) - } - - // Edit tokens are bearer secrets gated behind the Author URL — they - // must NOT appear in the JSON list output. The list view exposes - // id/kind/url/plan_file/created_at, matching the parity of the text - // columns plus a timestamp. (Decryption keys travel inside the URL - // fragment, the same way they do in the text output, so excluding - // them from --json would produce inconsistent surface area.) - for _, secret := range []string{"tok1", "tok2"} { - if strings.Contains(out, secret) { - t.Errorf("edit_token %q leaked in --json output: %s", secret, out) - } - } -} - -// TestRunShareListJSONEmpty verifies that `arc share list --json` with no -// shares returns an empty JSON array rather than the human-friendly -// "(no shares)" string, so JSON consumers can `[].length` safely. -func TestRunShareListJSONEmpty(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - srv := startTestPasteServer(t) - defer srv.Close() - - prev := outputJSON - outputJSON = true - t.Cleanup(func() { outputJSON = prev }) - - out := captureStdout(t, func() { - if err := runShareList(shareListCmd, nil); err != nil { - t.Fatalf("runShareList: %v", err) - } - }) - - var got []sharesconfig.Share - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("output is not valid JSON array: %v\noutput: %q", err, out) - } - if len(got) != 0 { - t.Errorf("expected empty array, got %+v", got) - } -} diff --git a/internal/api/openapi.gen.go b/internal/api/openapi.gen.go index 0f4a2b1..d777dbd 100644 --- a/internal/api/openapi.gen.go +++ b/internal/api/openapi.gen.go @@ -61,12 +61,6 @@ const ( Rejected PlanStatus = "rejected" ) -// Defines values for ShareKind. -const ( - Local ShareKind = "local" - Shared ShareKind = "shared" -) - // Defines values for Status. const ( StatusBlocked Status = "blocked" @@ -543,26 +537,12 @@ type ServerConfig struct { Port *int `json:"port,omitempty"` } -// Share defines model for Share. -type Share struct { - CreatedAt time.Time `json:"created_at"` - EditToken string `json:"edit_token"` - ID string `json:"id"` - KeyB64Url string `json:"key_b64url"` - Kind ShareKind `json:"kind"` - PlanFile *string `json:"plan_file,omitempty"` - URL string `json:"url"` -} - // ShareConfig defines model for ShareConfig. type ShareConfig struct { Author *string `json:"author,omitempty"` Server *string `json:"server,omitempty"` } -// ShareKind defines model for ShareKind. -type ShareKind string - // Statistics defines model for Statistics. type Statistics struct { AvgLeadTimeHours *float64 `json:"avg_lead_time_hours,omitempty"` @@ -668,16 +648,6 @@ type UpdatesConfig struct { // UpdatesConfigChannel defines model for UpdatesConfig.Channel. type UpdatesConfigChannel string -// UpsertShareRequest defines model for UpsertShareRequest. -type UpsertShareRequest struct { - EditToken string `json:"edit_token"` - ID string `json:"id"` - KeyB64Url string `json:"key_b64url"` - Kind ShareKind `json:"kind"` - PlanFile *string `json:"plan_file,omitempty"` - URL string `json:"url"` -} - // ActorHeader defines model for ActorHeader. type ActorHeader = string @@ -894,9 +864,6 @@ type AddDependencyJSONRequestBody = AddDependencyRequest // AddLabelToIssueJSONRequestBody defines body for AddLabelToIssue for application/json ContentType. type AddLabelToIssueJSONRequestBody = AddLabelToIssueRequest -// UpsertShareJSONRequestBody defines body for UpsertShare for application/json ContentType. -type UpsertShareJSONRequestBody = UpsertShareRequest - // ServerInterface represents all server handlers. type ServerInterface interface { // Get the current arc configuration @@ -1049,18 +1016,6 @@ type ServerInterface interface { // Get issues grouped by teammate role labels // (GET /projects/{projectId}/team-context) GetTeamContext(ctx echo.Context, projectID ProjectID, params GetTeamContextParams) error - // List authored shares from the local keyring - // (GET /shares) - ListShares(ctx echo.Context) error - // Insert or replace a share keyring entry - // (POST /shares) - UpsertShare(ctx echo.Context) error - // Remove a share from the keyring (idempotent) - // (DELETE /shares/{shareId}) - DeleteShare(ctx echo.Context, shareID string) error - // Get a single share keyring entry - // (GET /shares/{shareId}) - GetShare(ctx echo.Context, shareID string) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -2372,56 +2327,6 @@ func (w *ServerInterfaceWrapper) GetTeamContext(ctx echo.Context) error { return err } -// ListShares converts echo context to params. -func (w *ServerInterfaceWrapper) ListShares(ctx echo.Context) error { - var err error - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.ListShares(ctx) - return err -} - -// UpsertShare converts echo context to params. -func (w *ServerInterfaceWrapper) UpsertShare(ctx echo.Context) error { - var err error - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.UpsertShare(ctx) - return err -} - -// DeleteShare converts echo context to params. -func (w *ServerInterfaceWrapper) DeleteShare(ctx echo.Context) error { - var err error - // ------------- Path parameter "shareId" ------------- - var shareID string - - err = runtime.BindStyledParameterWithOptions("simple", "shareId", ctx.Param("shareId"), &shareID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter shareId: %s", err)) - } - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.DeleteShare(ctx, shareID) - return err -} - -// GetShare converts echo context to params. -func (w *ServerInterfaceWrapper) GetShare(ctx echo.Context) error { - var err error - // ------------- Path parameter "shareId" ------------- - var shareID string - - err = runtime.BindStyledParameterWithOptions("simple", "shareId", ctx.Param("shareId"), &shareID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter shareId: %s", err)) - } - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetShare(ctx, shareID) - return err -} - // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -2500,10 +2405,6 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/projects/:projectId/ready", wrapper.GetReadyWork) router.GET(baseURL+"/projects/:projectId/stats", wrapper.GetProjectStats) router.GET(baseURL+"/projects/:projectId/team-context", wrapper.GetTeamContext) - router.GET(baseURL+"/shares", wrapper.ListShares) - router.POST(baseURL+"/shares", wrapper.UpsertShare) - router.DELETE(baseURL+"/shares/:shareId", wrapper.DeleteShare) - router.GET(baseURL+"/shares/:shareId", wrapper.GetShare) } @@ -4368,126 +4269,6 @@ func (response GetTeamContext500JSONResponse) VisitGetTeamContextResponse(w http return json.NewEncoder(w).Encode(response) } -type ListSharesRequestObject struct { -} - -type ListSharesResponseObject interface { - VisitListSharesResponse(w http.ResponseWriter) error -} - -type ListShares200JSONResponse []Share - -func (response ListShares200JSONResponse) VisitListSharesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type ListShares500JSONResponse struct{ InternalErrorJSONResponse } - -func (response ListShares500JSONResponse) VisitListSharesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(500) - - return json.NewEncoder(w).Encode(response) -} - -type UpsertShareRequestObject struct { - Body *UpsertShareJSONRequestBody -} - -type UpsertShareResponseObject interface { - VisitUpsertShareResponse(w http.ResponseWriter) error -} - -type UpsertShare200JSONResponse Share - -func (response UpsertShare200JSONResponse) VisitUpsertShareResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type UpsertShare400JSONResponse struct{ BadRequestJSONResponse } - -func (response UpsertShare400JSONResponse) VisitUpsertShareResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(400) - - return json.NewEncoder(w).Encode(response) -} - -type UpsertShare500JSONResponse struct{ InternalErrorJSONResponse } - -func (response UpsertShare500JSONResponse) VisitUpsertShareResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(500) - - return json.NewEncoder(w).Encode(response) -} - -type DeleteShareRequestObject struct { - ShareID string `json:"shareId"` -} - -type DeleteShareResponseObject interface { - VisitDeleteShareResponse(w http.ResponseWriter) error -} - -type DeleteShare204Response struct { -} - -func (response DeleteShare204Response) VisitDeleteShareResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type DeleteShare500JSONResponse struct{ InternalErrorJSONResponse } - -func (response DeleteShare500JSONResponse) VisitDeleteShareResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(500) - - return json.NewEncoder(w).Encode(response) -} - -type GetShareRequestObject struct { - ShareID string `json:"shareId"` -} - -type GetShareResponseObject interface { - VisitGetShareResponse(w http.ResponseWriter) error -} - -type GetShare200JSONResponse Share - -func (response GetShare200JSONResponse) VisitGetShareResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetShare404JSONResponse struct{ NotFoundJSONResponse } - -func (response GetShare404JSONResponse) VisitGetShareResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(404) - - return json.NewEncoder(w).Encode(response) -} - -type GetShare500JSONResponse struct{ InternalErrorJSONResponse } - -func (response GetShare500JSONResponse) VisitGetShareResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(500) - - return json.NewEncoder(w).Encode(response) -} - // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get the current arc configuration @@ -4640,18 +4421,6 @@ type StrictServerInterface interface { // Get issues grouped by teammate role labels // (GET /projects/{projectId}/team-context) GetTeamContext(ctx context.Context, request GetTeamContextRequestObject) (GetTeamContextResponseObject, error) - // List authored shares from the local keyring - // (GET /shares) - ListShares(ctx context.Context, request ListSharesRequestObject) (ListSharesResponseObject, error) - // Insert or replace a share keyring entry - // (POST /shares) - UpsertShare(ctx context.Context, request UpsertShareRequestObject) (UpsertShareResponseObject, error) - // Remove a share from the keyring (idempotent) - // (DELETE /shares/{shareId}) - DeleteShare(ctx context.Context, request DeleteShareRequestObject) (DeleteShareResponseObject, error) - // Get a single share keyring entry - // (GET /shares/{shareId}) - GetShare(ctx context.Context, request GetShareRequestObject) (GetShareResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -6061,217 +5830,109 @@ func (sh *strictHandler) GetTeamContext(ctx echo.Context, projectID ProjectID, p return nil } -// ListShares operation middleware -func (sh *strictHandler) ListShares(ctx echo.Context) error { - var request ListSharesRequestObject - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.ListShares(ctx.Request().Context(), request.(ListSharesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "ListShares") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(ListSharesResponseObject); ok { - return validResponse.VisitListSharesResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - -// UpsertShare operation middleware -func (sh *strictHandler) UpsertShare(ctx echo.Context) error { - var request UpsertShareRequestObject - - var body UpsertShareJSONRequestBody - if err := ctx.Bind(&body); err != nil { - return err - } - request.Body = &body - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.UpsertShare(ctx.Request().Context(), request.(UpsertShareRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "UpsertShare") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(UpsertShareResponseObject); ok { - return validResponse.VisitUpsertShareResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - -// DeleteShare operation middleware -func (sh *strictHandler) DeleteShare(ctx echo.Context, shareID string) error { - var request DeleteShareRequestObject - - request.ShareID = shareID - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteShare(ctx.Request().Context(), request.(DeleteShareRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteShare") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(DeleteShareResponseObject); ok { - return validResponse.VisitDeleteShareResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - -// GetShare operation middleware -func (sh *strictHandler) GetShare(ctx echo.Context, shareID string) error { - var request GetShareRequestObject - - request.ShareID = shareID - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetShare(ctx.Request().Context(), request.(GetShareRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetShare") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(GetShareResponseObject); ok { - return validResponse.VisitGetShareResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "B0pfNPn/AAAA//+ibdChq7cAAA==", + "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", + "DLnEM5j4ezIldJ++Pv1/AAAA///5geWhaq4AAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/api/paste_routes.go b/internal/api/paste_routes.go deleted file mode 100644 index fc32b30..0000000 --- a/internal/api/paste_routes.go +++ /dev/null @@ -1,18 +0,0 @@ -package api - -import ( - "database/sql" - - "github.com/labstack/echo/v4" - "github.com/sentiolabs/arc/internal/paste" - pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" -) - -// registerPasteRoutes mounts the paste package's handlers under /api/paste. -// The caller passes the same DB used for arc's main storage; paste tables -// are added by pastesqlite.Apply during startup. -func registerPasteRoutes(e *echo.Echo, db *sql.DB) { - store := pastesqlite.New(db) - handlers := paste.NewHandlers(store) - handlers.Register(e.Group("/api/paste")) -} diff --git a/internal/api/paste_routes_test.go b/internal/api/paste_routes_test.go deleted file mode 100644 index 3dbb603..0000000 --- a/internal/api/paste_routes_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package api //nolint:testpackage // tests use internal helpers that access unexported fields - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "github.com/sentiolabs/arc/internal/paste" - "github.com/sentiolabs/arc/internal/storage/sqlite" - "github.com/sentiolabs/arc/web" -) - -// testServerWithDB creates a test server with paste routes registered. -func testServerWithDB(t *testing.T) (*Server, func()) { - t.Helper() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.db") - store, err := sqlite.New(dbPath) - if err != nil { - t.Fatalf("failed to create store: %v", err) - } - - server := New(ServerOptions{ - Address: ":0", - Store: store, - DB: store.DB(), - }) - - cleanup := func() { - store.Close() - } - - return server, cleanup -} - -func TestPasteRoutesMounted(t *testing.T) { - srv, cleanup := testServerWithDB(t) - defer cleanup() - - body, _ := json.Marshal(paste.CreatePasteRequest{ - PlanBlob: []byte{1, 2, 3}, - PlanIV: []byte{4, 5, 6}, - SchemaVer: 1, - }) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - srv.echo.ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) - } - var resp paste.CreatePasteResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - if resp.ID == "" { - t.Errorf("missing id in response: %+v", resp) - } - if resp.EditToken == "" { - t.Errorf("missing edit_token in response: %+v", resp) - } -} - -func TestShareRouteFallsBackToSPA(t *testing.T) { - if !web.Enabled { - t.Skip("skipping SPA fallback test: webui not compiled (run with -tags webui)") - } - - srv, cleanup := testServerWithDB(t) - defer cleanup() - - req := httptest.NewRequest(http.MethodGet, "/share/abc", nil) - rec := httptest.NewRecorder() - srv.echo.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 (SPA fallback), got %d", rec.Code) - } - if !bytes.Contains(rec.Body.Bytes(), []byte(" 0: - log.Printf("imported %d legacy share(s) from %s", n, path) - } - } - // Start server in goroutine errCh := make(chan error, 1) go func() { diff --git a/internal/sharesconfig/sharesconfig.go b/internal/sharesconfig/sharesconfig.go deleted file mode 100644 index eb59021..0000000 --- a/internal/sharesconfig/sharesconfig.go +++ /dev/null @@ -1,154 +0,0 @@ -// Package sharesconfig provides backward-compatible access to the user's -// share keyring. As of v0.next, the keyring is stored in arc-server's -// SQLite database (data.db); this package wraps the /api/v1/shares HTTP -// endpoints to preserve the existing public API used by cmd/arc/share.go. -package sharesconfig - -import ( - "errors" - "os" - "path/filepath" - "time" - - "github.com/sentiolabs/arc/internal/client" - "github.com/sentiolabs/arc/internal/types" -) - -// ErrShareNotFound is returned by Find when no share matches the given ID. -// Preserved for backward compatibility with existing callers. -var ErrShareNotFound = errors.New("share not found") - -// Share is the public representation of a keyring entry. Field names -// match the legacy shares.json on-disk format, so callers built against -// the JSON-backed implementation continue to compile. -type Share struct { - ID string `json:"id"` - Kind string `json:"kind"` // "local" | "shared" - URL string `json:"url"` - KeyB64Url string `json:"key_b64url"` - EditToken string `json:"edit_token"` - PlanFile string `json:"plan_file,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// File preserves the legacy shape that callers iterate over. -type File struct { - Shares []Share `json:"shares"` -} - -// Client is the minimal HTTP client surface this package uses. -// The real implementation is internal/client.Client; tests inject fakes. -type Client interface { - ListShares() ([]*types.Share, error) - GetShare(id string) (*types.Share, error) - UpsertShare(share *types.Share) (*types.Share, error) - DeleteShare(id string) error -} - -// clientFactory is injected at process startup (typically from cmd/arc/main.go). -// Tests use SetClientFactory to inject fakes. -var clientFactory func() (Client, error) - -// SetClientFactory installs the function used to obtain the HTTP client. -// Must be called before any sharesconfig package function is invoked. -func SetClientFactory(fn func() (Client, error)) { - clientFactory = fn -} - -// getClient is the package-internal accessor; errors clearly if the factory -// hasn't been set, so misuse is loud. -func getClient() (Client, error) { - if clientFactory == nil { - return nil, errors.New("sharesconfig: client factory not initialized: call SetClientFactory in main") - } - return clientFactory() -} - -// LegacyPath returns the path to the legacy ~/.arc/shares.json file. -// Used by the server-side import logic to find the file at startup. -// The CLI no longer reads this path. -func LegacyPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".arc", "shares.json"), nil -} - -// Load returns all keyring entries, matching the legacy contract. -func Load() (*File, error) { - c, err := getClient() - if err != nil { - return nil, err - } - remote, err := c.ListShares() - if err != nil { - return nil, err - } - f := &File{Shares: make([]Share, 0, len(remote))} - for _, r := range remote { - f.Shares = append(f.Shares, fromTypes(r)) - } - return f, nil -} - -// Add upserts a Share into the keyring. -func Add(s Share) error { - c, err := getClient() - if err != nil { - return err - } - _, err = c.UpsertShare(toTypes(s)) - return err -} - -// Find returns the keyring entry for the given share ID, or -// ErrShareNotFound if no entry matches. -func Find(id string) (*Share, error) { - c, err := getClient() - if err != nil { - return nil, err - } - remote, err := c.GetShare(id) - if err != nil { - if errors.Is(err, client.ErrShareNotFound) { - return nil, ErrShareNotFound - } - return nil, err - } - s := fromTypes(remote) - return &s, nil -} - -// Remove deletes the keyring entry for the given ID. No-op if missing. -func Remove(id string) error { - c, err := getClient() - if err != nil { - return err - } - return c.DeleteShare(id) -} - -func toTypes(s Share) *types.Share { - return &types.Share{ - ID: s.ID, - Kind: types.ShareKind(s.Kind), - URL: s.URL, - KeyB64Url: s.KeyB64Url, - EditToken: s.EditToken, - PlanFile: s.PlanFile, - CreatedAt: s.CreatedAt, - } -} - -func fromTypes(t *types.Share) Share { - return Share{ - ID: t.ID, - Kind: string(t.Kind), - URL: t.URL, - KeyB64Url: t.KeyB64Url, - EditToken: t.EditToken, - PlanFile: t.PlanFile, - CreatedAt: t.CreatedAt, - } -} diff --git a/internal/sharesconfig/sharesconfig_test.go b/internal/sharesconfig/sharesconfig_test.go deleted file mode 100644 index 4318169..0000000 --- a/internal/sharesconfig/sharesconfig_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package sharesconfig_test - -import ( - "errors" - "strings" - "testing" - "time" - - "github.com/sentiolabs/arc/internal/client" - "github.com/sentiolabs/arc/internal/sharesconfig" - "github.com/sentiolabs/arc/internal/types" -) - -type fakeClient struct { - store map[string]*types.Share -} - -func newFakeClient() *fakeClient { - return &fakeClient{store: map[string]*types.Share{}} -} - -func (f *fakeClient) ListShares() ([]*types.Share, error) { - out := make([]*types.Share, 0, len(f.store)) - for _, s := range f.store { - out = append(out, s) - } - return out, nil -} - -func (f *fakeClient) GetShare(id string) (*types.Share, error) { - s, ok := f.store[id] - if !ok { - return nil, client.ErrShareNotFound - } - return s, nil -} - -func (f *fakeClient) UpsertShare(s *types.Share) (*types.Share, error) { - f.store[s.ID] = s - return s, nil -} - -func (f *fakeClient) DeleteShare(id string) error { - delete(f.store, id) - return nil -} - -func withFake(t *testing.T) *fakeClient { - t.Helper() - fake := newFakeClient() - sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { return fake, nil }) - t.Cleanup(func() { sharesconfig.SetClientFactory(nil) }) - return fake -} - -func TestAddAndFind(t *testing.T) { - withFake(t) - s := sharesconfig.Share{ID: "x", Kind: "local", URL: "u", KeyB64Url: "k", EditToken: "t", CreatedAt: time.Now()} - if err := sharesconfig.Add(s); err != nil { - t.Fatalf("add: %v", err) - } - got, err := sharesconfig.Find("x") - if err != nil { - t.Fatalf("find: %v", err) - } - if got.ID != "x" || got.URL != "u" { - t.Errorf("unexpected: %+v", got) - } -} - -func TestFindNotFound(t *testing.T) { - withFake(t) - _, err := sharesconfig.Find("missing") - if !errors.Is(err, sharesconfig.ErrShareNotFound) { - t.Errorf("expected ErrShareNotFound, got %v", err) - } -} - -func TestLoadEmpty(t *testing.T) { - withFake(t) - f, err := sharesconfig.Load() - if err != nil { - t.Fatalf("load: %v", err) - } - if len(f.Shares) != 0 { - t.Errorf("expected empty, got %d", len(f.Shares)) - } -} - -func TestRemove(t *testing.T) { - fake := withFake(t) - fake.store["x"] = &types.Share{ID: "x"} - if err := sharesconfig.Remove("x"); err != nil { - t.Fatalf("remove: %v", err) - } - if _, ok := fake.store["x"]; ok { - t.Errorf("expected entry removed") - } -} - -func TestLegacyPath(t *testing.T) { - p, err := sharesconfig.LegacyPath() - if err != nil { - t.Fatalf("legacy path: %v", err) - } - if p == "" || !strings.HasSuffix(p, "/.arc/shares.json") { - t.Errorf("unexpected legacy path: %s", p) - } -} - -func TestNoFactorySet(t *testing.T) { - sharesconfig.SetClientFactory(nil) - _, err := sharesconfig.Load() - if err == nil { - t.Error("expected error when factory not set, got nil") - } -} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index fc7b5ec..2b659b0 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -506,45 +506,6 @@ export interface paths { patch?: never; trace?: never; }; - "/shares": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List authored shares from the local keyring */ - get: operations["listShares"]; - put?: never; - /** Insert or replace a share keyring entry */ - post: operations["upsertShare"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/shares/{shareId}": { - parameters: { - query?: never; - header?: never; - path: { - /** @description Share ID (server-generated by the paste host) */ - shareId: string; - }; - cookie?: never; - }; - /** Get a single share keyring entry */ - get: operations["getShare"]; - put?: never; - post?: never; - /** Remove a share from the keyring (idempotent) */ - delete: operations["deleteShare"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/projects/{projectId}/issues/{issueId}/labels": { parameters: { query?: never; @@ -931,26 +892,6 @@ export interface components { AddLabelToIssueRequest: { label: string; }; - Share: { - id: string; - kind: components["schemas"]["ShareKind"]; - url: string; - key_b64url: string; - edit_token: string; - plan_file?: string; - /** Format: date-time */ - created_at: string; - }; - /** @enum {string} */ - ShareKind: "local" | "shared"; - UpsertShareRequest: { - id: string; - kind: components["schemas"]["ShareKind"]; - url: string; - key_b64url: string; - edit_token: string; - plan_file?: string; - }; Comment: { /** Format: int64 */ id: number; @@ -2223,100 +2164,6 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; - listShares: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List of shares (newest first) */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Share"][]; - }; - }; - 500: components["responses"]["InternalError"]; - }; - }; - upsertShare: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpsertShareRequest"]; - }; - }; - responses: { - /** @description Share stored */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Share"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 500: components["responses"]["InternalError"]; - }; - }; - getShare: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Share ID (server-generated by the paste host) */ - shareId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Share record */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Share"]; - }; - }; - 404: components["responses"]["NotFound"]; - 500: components["responses"]["InternalError"]; - }; - }; - deleteShare: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Share ID (server-generated by the paste host) */ - shareId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Share removed (or absent — same response) */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 500: components["responses"]["InternalError"]; - }; - }; addLabelToIssue: { parameters: { query?: never; From 6fbb3230d6f18e0af35050d333a5ab18a04a6189 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:30:15 -0700 Subject: [PATCH 05/11] refactor(storage,types): remove share/paste storage layer and paste engine --- internal/api/workspace_paths_test.go | 20 -- internal/paste/anchor.go | 169 ----------- internal/paste/anchor_test.go | 143 --------- internal/paste/crypto.go | 70 ----- internal/paste/crypto_test.go | 44 --- internal/paste/crypto_xlang_test.go | 85 ------ internal/paste/handlers.go | 237 --------------- internal/paste/handlers_test.go | 249 ---------------- internal/paste/sqlite/migrations.go | 70 ----- internal/paste/sqlite/migrations/001_init.sql | 20 -- internal/paste/sqlite/store.go | 133 --------- internal/paste/sqlite/store_test.go | 123 -------- internal/paste/storage.go | 29 -- internal/paste/testdata/README.md | 15 - internal/paste/testdata/xlang_fixtures.json | 32 -- internal/paste/types.go | 46 --- internal/paste/types_test.go | 33 --- internal/storage/sqlite/db/models.go | 10 - internal/storage/sqlite/db/queries/shares.sql | 19 -- internal/storage/sqlite/db/schema.sql | 13 - internal/storage/sqlite/db/shares.sql.go | 110 ------- internal/storage/sqlite/shares.go | 118 -------- internal/storage/sqlite/shares_test.go | 275 ------------------ internal/storage/sqlite/store.go | 11 +- internal/storage/storage.go | 11 - internal/types/shares_test.go | 79 ----- internal/types/types.go | 49 ---- 27 files changed, 2 insertions(+), 2211 deletions(-) delete mode 100644 internal/paste/anchor.go delete mode 100644 internal/paste/anchor_test.go delete mode 100644 internal/paste/crypto.go delete mode 100644 internal/paste/crypto_test.go delete mode 100644 internal/paste/crypto_xlang_test.go delete mode 100644 internal/paste/handlers.go delete mode 100644 internal/paste/handlers_test.go delete mode 100644 internal/paste/sqlite/migrations.go delete mode 100644 internal/paste/sqlite/migrations/001_init.sql delete mode 100644 internal/paste/sqlite/store.go delete mode 100644 internal/paste/sqlite/store_test.go delete mode 100644 internal/paste/storage.go delete mode 100644 internal/paste/testdata/README.md delete mode 100644 internal/paste/testdata/xlang_fixtures.json delete mode 100644 internal/paste/types.go delete mode 100644 internal/paste/types_test.go delete mode 100644 internal/storage/sqlite/db/queries/shares.sql delete mode 100644 internal/storage/sqlite/db/shares.sql.go delete mode 100644 internal/storage/sqlite/shares.go delete mode 100644 internal/storage/sqlite/shares_test.go delete mode 100644 internal/types/shares_test.go diff --git a/internal/api/workspace_paths_test.go b/internal/api/workspace_paths_test.go index bb2de4b..5f6d049 100644 --- a/internal/api/workspace_paths_test.go +++ b/internal/api/workspace_paths_test.go @@ -318,26 +318,6 @@ func (m *mockWPStore) GetAgentSummariesForSessions( panic("not implemented") } -func (m *mockWPStore) UpsertShare(_ context.Context, _ *types.Share) error { - panic("not implemented") -} - -func (m *mockWPStore) UpsertShares(_ context.Context, _ []*types.Share) error { - panic("not implemented") -} - -func (m *mockWPStore) GetShare(_ context.Context, _ string) (*types.Share, error) { - panic("not implemented") -} - -func (m *mockWPStore) ListShares(_ context.Context) ([]*types.Share, error) { - panic("not implemented") -} - -func (m *mockWPStore) DeleteShare(_ context.Context, _ string) error { - panic("not implemented") -} - func (m *mockWPStore) Close() error { return nil } func (m *mockWPStore) Path() string { return "" } diff --git a/internal/paste/anchor.go b/internal/paste/anchor.go deleted file mode 100644 index 011ba0d..0000000 --- a/internal/paste/anchor.go +++ /dev/null @@ -1,169 +0,0 @@ -// Anchor resolution for replaying reviewer annotations against a possibly- -// edited plan. Mirror of web/src/lib/paste/anchor.ts; the two sides MUST -// stay in sync since both consume the same encrypted Anchor payloads. -// -// 4-step fallback (same as the TS): -// 1. Exact: line_start..line_end still contain quoted_text → status=ok -// 2. Heading-scoped: search the 50 lines after heading_slug → status=drifted -// 3. Fuzzy: context_before + quoted_text + context_after appears in the plan -// → status=drifted -// 4. None of the above: status=orphaned, original line numbers preserved -// for display purposes. -package paste - -import ( - "regexp" - "strings" -) - -// headingWindowLines bounds the heading-scoped fuzzy search at step 2 of -// ResolveAnchor — large enough to span typical sections, small enough that an -// unrelated re-occurrence later in the plan can't accidentally match. -const headingWindowLines = 50 - -// Anchor mirrors the TS Anchor type (web/src/lib/paste/types.ts). Encoded as -// JSON inside encrypted comment events; decoded here only when the CLI needs -// to relocate a comment against a current plan. -type Anchor struct { - LineStart int `json:"line_start"` - LineEnd int `json:"line_end"` - CharStart *int `json:"char_start,omitempty"` - CharEnd *int `json:"char_end,omitempty"` - QuotedText string `json:"quoted_text"` - ContextBefore string `json:"context_before,omitempty"` - ContextAfter string `json:"context_after,omitempty"` - HeadingSlug string `json:"heading_slug,omitempty"` -} - -// AnchorStatus values for AnchorResolution.Status. Mirrored on the SPA side; -// adding/renaming a status here breaks the cross-language contract. -const ( - // AnchorStatusOK means the original line numbers still contain the quoted - // text — no relocation was needed. - AnchorStatusOK = "ok" - // AnchorStatusDrifted means the anchor was relocated via heading scope - // or fuzzy context match; the new line numbers are best-effort. - AnchorStatusDrifted = "drifted" - // AnchorStatusOrphaned means we couldn't relocate the anchor at all; the - // original line numbers are preserved for display only. - AnchorStatusOrphaned = "orphaned" -) - -// AnchorResolution is the result of running ResolveAnchor against a plan. -// Status disambiguates "found at original location" from "relocated" from -// "couldn't find at all" — agents care about this for deciding whether to -// trust the line numbers or fall back to a Grep on QuotedText. -type AnchorResolution struct { - LineStart int `json:"line_start"` - LineEnd int `json:"line_end"` - Status string `json:"status"` // one of AnchorStatus* constants -} - -// ResolveAnchor finds the current location of an anchor in plan markdown. -// Returns the original line numbers + status="orphaned" if every fallback -// fails — never returns an error. -func ResolveAnchor(plan string, a Anchor) AnchorResolution { - lines := strings.Split(plan, "\n") - - // Step 1: exact location still holds. - if a.LineStart >= 1 && a.LineEnd <= len(lines) && a.LineStart <= a.LineEnd { - slice := strings.Join(lines[a.LineStart-1:a.LineEnd], "\n") - if strings.Contains(slice, a.QuotedText) { - return AnchorResolution{ - LineStart: a.LineStart, - LineEnd: a.LineEnd, - Status: AnchorStatusOK, - } - } - } - - // Step 2: heading-scoped — look 50 lines past the matching heading. - if a.HeadingSlug != "" { - if hi := findHeadingIndex(lines, a.HeadingSlug); hi >= 0 { - end := min(hi+headingWindowLines, len(lines)) - window := strings.Join(lines[hi:end], "\n") - if off := strings.Index(window, a.QuotedText); off >= 0 { - lineNum := hi + 1 + countNewlinesBefore(window, off) - return AnchorResolution{ - LineStart: lineNum, - LineEnd: lineNum + countNewlinesBefore(a.QuotedText, len(a.QuotedText)), - Status: AnchorStatusDrifted, - } - } - } - } - - // Step 3: fuzzy — match the surrounding window verbatim. - if a.ContextBefore != "" && a.ContextAfter != "" { - needle := a.ContextBefore + a.QuotedText + a.ContextAfter - if idx := strings.Index(plan, needle); idx >= 0 { - startOff := idx + len(a.ContextBefore) - lineNum := countNewlinesBefore(plan, startOff) + 1 - return AnchorResolution{ - LineStart: lineNum, - LineEnd: lineNum + countNewlinesBefore(a.QuotedText, len(a.QuotedText)), - Status: AnchorStatusDrifted, - } - } - } - - // Step 4: orphaned. Preserve original coords so the UI can still display - // "this comment used to be at line X" rather than rendering nothing. - return AnchorResolution{ - LineStart: a.LineStart, - LineEnd: a.LineEnd, - Status: AnchorStatusOrphaned, - } -} - -// Snippet returns up to ~5 lines around the resolved anchor — handy for -// LLM consumers that want a small chunk of context without re-reading the -// whole plan. Returns "" if the resolution is orphaned (no reliable -// location to extract from). -func Snippet(plan string, r AnchorResolution) string { - if r.Status == AnchorStatusOrphaned { - return "" - } - lines := strings.Split(plan, "\n") - const padding = 2 - start := max(r.LineStart-1-padding, 0) - end := min(r.LineEnd+padding, len(lines)) - if start >= end { - return "" - } - return strings.Join(lines[start:end], "\n") -} - -func findHeadingIndex(lines []string, slug string) int { - re := regexp.MustCompile(`^#+\s+(.*)$`) - for i, line := range lines { - m := re.FindStringSubmatch(line) - if len(m) == 2 && Slugify(m[1]) == slug { - return i - } - } - return -1 -} - -// Slugify mirrors web/src/lib/paste/anchor.ts:slugify so heading_slug values -// produced by the SPA match what we recompute here. Lowercase ASCII letters -// + digits + hyphens; whitespace becomes '-'. -func Slugify(text string) string { - lowered := strings.ToLower(text) - // Replace anything that isn't [a-z0-9 -] with empty string. - stripped := nonSlugChars.ReplaceAllString(lowered, "") - trimmed := strings.TrimSpace(stripped) - return whitespaceRun.ReplaceAllString(trimmed, "-") -} - -var ( - nonSlugChars = regexp.MustCompile(`[^a-z0-9\s-]`) - whitespaceRun = regexp.MustCompile(`\s+`) -) - -func countNewlinesBefore(s string, idx int) int { - if idx > len(s) { - idx = len(s) - } - return strings.Count(s[:idx], "\n") -} diff --git a/internal/paste/anchor_test.go b/internal/paste/anchor_test.go deleted file mode 100644 index 521532c..0000000 --- a/internal/paste/anchor_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package paste_test - -import ( - "strings" - "testing" - - "github.com/sentiolabs/arc/internal/paste" -) - -const samplePlan = "# Title\n\nFirst paragraph.\nSecond paragraph.\n## Sub\nThird.\n" - -func TestResolveAnchor_Ok(t *testing.T) { - r := paste.ResolveAnchor(samplePlan, paste.Anchor{ - LineStart: 3, - LineEnd: 3, - QuotedText: "First paragraph.", - }) - if r.Status != "ok" { - t.Errorf("status = %q, want ok", r.Status) - } - if r.LineStart != 3 || r.LineEnd != 3 { - t.Errorf("line range = (%d, %d), want (3, 3)", r.LineStart, r.LineEnd) - } -} - -func TestResolveAnchor_DriftedViaHeading(t *testing.T) { - // Insert a prelude — the same paragraph now lives at line 5, not 3. - // The heading_slug fallback must relocate it. - edited := "PRELUDE\n# Title\n\nMore content.\nFirst paragraph.\n## Sub\nThird.\n" - r := paste.ResolveAnchor(edited, paste.Anchor{ - LineStart: 3, - LineEnd: 3, - QuotedText: "First paragraph.", - HeadingSlug: "title", - }) - if r.Status != paste.AnchorStatusDrifted { - t.Errorf("status = %q, want drifted", r.Status) - } - if r.LineStart != 5 { - t.Errorf("line_start = %d, want 5", r.LineStart) - } -} - -func TestResolveAnchor_DriftedViaContext(t *testing.T) { - // Heading was renamed (slug doesn't match) but the surrounding context - // is intact, so the fuzzy fallback should still find the location. - edited := "# A different title\n\nFirst paragraph.\nSecond paragraph.\n" - r := paste.ResolveAnchor(edited, paste.Anchor{ - LineStart: 5, - LineEnd: 5, - QuotedText: "First paragraph.", - HeadingSlug: "old-slug", - ContextBefore: "\n\n", - ContextAfter: "\nSecond", - }) - if r.Status != paste.AnchorStatusDrifted { - t.Errorf("status = %q, want drifted via fuzzy match", r.Status) - } -} - -func TestResolveAnchor_Orphaned(t *testing.T) { - edited := "# Title\n\nDifferent stuff.\n" - r := paste.ResolveAnchor(edited, paste.Anchor{ - LineStart: 3, - LineEnd: 3, - QuotedText: "First paragraph.", - HeadingSlug: "title", - }) - if r.Status != "orphaned" { - t.Errorf("status = %q, want orphaned", r.Status) - } - // Original coordinates preserved so callers can still display them. - if r.LineStart != 3 || r.LineEnd != 3 { - t.Errorf("orphaned should preserve original coords; got (%d, %d)", r.LineStart, r.LineEnd) - } -} - -func TestResolveAnchor_OutOfRangeFallsThrough(t *testing.T) { - // Anchor refers to a line beyond the plan — must NOT panic, must fall - // through to subsequent steps. - short := "# Title\n\nOnly one paragraph.\n" - r := paste.ResolveAnchor(short, paste.Anchor{ - LineStart: 500, - LineEnd: 500, - QuotedText: "Only one paragraph.", - HeadingSlug: "title", - }) - if r.Status != paste.AnchorStatusDrifted { - t.Errorf("status = %q, want drifted (fell through to heading match)", r.Status) - } -} - -func TestSlugify(t *testing.T) { - cases := map[string]string{ - "Title": "title", - "Hello World": "hello-world", - "Multi Spaces": "multi-spaces", - "With-Dashes": "with-dashes", - "Punctuation!?.": "punctuation", - "Mixed Case 123": "mixed-case-123", - " Trim Me ": "trim-me", - // Non-ASCII letters are stripped char-by-char (the regex is `[^a-z0-9\s-]`), - // leaving only the ASCII letters embedded in the words. Matches what the - // TS implementation produces. - "Über Größe": "ber-gre", - } - for in, want := range cases { - if got := paste.Slugify(in); got != want { - t.Errorf("paste.Slugify(%q) = %q, want %q", in, got, want) - } - } -} - -func TestSlugify_TSCompatibility(t *testing.T) { - // Crucially this MUST match what web/src/lib/paste/anchor.ts produces, - // since the SPA writes heading_slug values into the encrypted anchor. - // If these diverge, drifted/heading-scoped resolution silently fails. - if got := paste.Slugify("Goal"); got != "goal" { - t.Errorf(`paste.Slugify("Goal") = %q, want "goal"`, got) - } - if got := paste.Slugify("Approach"); got != "approach" { - t.Errorf(`paste.Slugify("Approach") = %q, want "approach"`, got) - } -} - -func TestSnippet(t *testing.T) { - r := paste.AnchorResolution{LineStart: 3, LineEnd: 3, Status: "ok"} - got := paste.Snippet(samplePlan, r) - // Should include lines 1-5 (line 3 ± 2 padding). - if !strings.Contains(got, "First paragraph.") { - t.Errorf("snippet missing the anchor line; got: %q", got) - } - if !strings.Contains(got, "# Title") { - t.Errorf("snippet missing leading context; got: %q", got) - } -} - -func TestSnippet_OrphanedReturnsEmpty(t *testing.T) { - r := paste.AnchorResolution{LineStart: 99, LineEnd: 99, Status: "orphaned"} - if got := paste.Snippet(samplePlan, r); got != "" { - t.Errorf("orphaned should yield empty snippet; got %q", got) - } -} diff --git a/internal/paste/crypto.go b/internal/paste/crypto.go deleted file mode 100644 index 2b6d35d..0000000 --- a/internal/paste/crypto.go +++ /dev/null @@ -1,70 +0,0 @@ -package paste - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/json" - "errors" -) - -// KeySize is the AES-256-GCM key length in bytes used by all paste crypto. -const KeySize = 32 - -// GenerateKey returns a fresh random 32-byte key suitable for paste encryption. -func GenerateKey() ([]byte, error) { - key := make([]byte, KeySize) - _, err := rand.Read(key) - return key, err -} - -// EncryptJSON marshals v to JSON and encrypts it with AES-256-GCM under key, -// returning the ciphertext and the freshly generated nonce (iv). The nonce is -// drawn fresh from crypto/rand on every call — callers must NOT reuse a nonce -// with the same key, which would catastrophically break GCM's confidentiality. -func EncryptJSON(v any, key []byte) (ciphertext, iv []byte, err error) { - if len(key) != KeySize { - return nil, nil, errors.New("paste: key must be 32 bytes") - } - plain, err := json.Marshal(v) - if err != nil { - return nil, nil, err - } - block, err := aes.NewCipher(key) - if err != nil { - return nil, nil, err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, nil, err - } - iv = make([]byte, gcm.NonceSize()) - if _, err := rand.Read(iv); err != nil { - return nil, nil, err - } - ciphertext = gcm.Seal(nil, iv, plain, nil) - return ciphertext, iv, nil -} - -// DecryptJSON inverts EncryptJSON: it decrypts ciphertext under key with the -// given nonce iv and unmarshals the plaintext JSON into v. Returns an error -// if the GCM tag fails to verify, the key is wrong, or the plaintext is not -// valid JSON for the target type. -func DecryptJSON(ciphertext, iv, key []byte, v any) error { - if len(key) != KeySize { - return errors.New("paste: key must be 32 bytes") - } - block, err := aes.NewCipher(key) - if err != nil { - return err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return err - } - plain, err := gcm.Open(nil, iv, ciphertext, nil) - if err != nil { - return err - } - return json.Unmarshal(plain, v) -} diff --git a/internal/paste/crypto_test.go b/internal/paste/crypto_test.go deleted file mode 100644 index 323a5af..0000000 --- a/internal/paste/crypto_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package paste_test - -import ( - "bytes" - "testing" - - "github.com/sentiolabs/arc/internal/paste" -) - -func TestEncryptDecryptRoundtrip(t *testing.T) { - key, err := paste.GenerateKey() - if err != nil { - t.Fatal(err) - } - in := map[string]any{"hello": "world", "n": float64(42)} - ct, iv, err := paste.EncryptJSON(in, key) - if err != nil { - t.Fatal(err) - } - var out map[string]any - if err := paste.DecryptJSON(ct, iv, key, &out); err != nil { - t.Fatalf("DecryptJSON: %v", err) - } - if out["hello"] != "world" { - t.Errorf("roundtrip mismatch: %+v", out) - } -} - -func TestDecryptWithWrongKeyFails(t *testing.T) { - k1, _ := paste.GenerateKey() - k2, _ := paste.GenerateKey() - ct, iv, _ := paste.EncryptJSON("secret", k1) - var out string - if err := paste.DecryptJSON(ct, iv, k2, &out); err == nil { - t.Error("expected decrypt to fail with wrong key") - } -} - -func TestKeySizeValidation(t *testing.T) { - short := bytes.Repeat([]byte{1}, 16) - if _, _, err := paste.EncryptJSON("x", short); err == nil { - t.Error("expected error for short key") - } -} diff --git a/internal/paste/crypto_xlang_test.go b/internal/paste/crypto_xlang_test.go deleted file mode 100644 index 7d3790b..0000000 --- a/internal/paste/crypto_xlang_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package paste_test - -import ( - "encoding/base64" - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/sentiolabs/arc/internal/paste" -) - -type xlangFixture struct { - Name string `json:"name"` - KeyB64Url string `json:"key_b64url"` - Plaintext json.RawMessage `json:"plaintext"` - CiphertextB64 string `json:"ciphertext_b64"` - IvB64 string `json:"iv_b64"` -} - -func TestCryptoXLangFixtures(t *testing.T) { - data, err := os.ReadFile(filepath.Join("testdata", "xlang_fixtures.json")) - if err != nil { - t.Fatal(err) - } - var fixtures []xlangFixture - if err := json.Unmarshal(data, &fixtures); err != nil { - t.Fatal(err) - } - if len(fixtures) == 0 { - t.Fatal("no fixtures loaded") - } - for _, f := range fixtures { - t.Run(f.Name, func(t *testing.T) { - key, err := base64UrlDecode(f.KeyB64Url) - if err != nil { - t.Fatal(err) - } - ct, _ := base64.StdEncoding.DecodeString(f.CiphertextB64) - iv, _ := base64.StdEncoding.DecodeString(f.IvB64) - var got json.RawMessage - if err := paste.DecryptJSON(ct, iv, key, &got); err != nil { - t.Fatalf("decrypt: %v", err) - } - var a, b any - _ = json.Unmarshal(f.Plaintext, &a) - _ = json.Unmarshal(got, &b) - if !reflect.DeepEqual(a, b) { - t.Errorf("plaintext mismatch:\nwant %s\ngot %s", f.Plaintext, got) - } - }) - } -} - -func TestCryptoXLangRoundtrip(t *testing.T) { - // For each fixture, also verify that re-encrypting and re-decrypting in Go - // produces the same plaintext (catches Go-internal regressions). - data, _ := os.ReadFile(filepath.Join("testdata", "xlang_fixtures.json")) - var fixtures []xlangFixture - _ = json.Unmarshal(data, &fixtures) - for _, f := range fixtures { - t.Run(f.Name+"-roundtrip", func(t *testing.T) { - key, _ := base64UrlDecode(f.KeyB64Url) - ct, iv, err := paste.EncryptJSON(f.Plaintext, key) - if err != nil { - t.Fatal(err) - } - var out json.RawMessage - if err := paste.DecryptJSON(ct, iv, key, &out); err != nil { - t.Fatal(err) - } - }) - } -} - -func base64UrlDecode(s string) ([]byte, error) { - switch len(s) % 4 { - case 2: - s += "==" - case 3: - s += "=" - } - return base64.URLEncoding.DecodeString(s) -} diff --git a/internal/paste/handlers.go b/internal/paste/handlers.go deleted file mode 100644 index 27989ce..0000000 --- a/internal/paste/handlers.go +++ /dev/null @@ -1,237 +0,0 @@ -package paste - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "net/http" - "strings" - "time" - - "github.com/labstack/echo/v4" -) - -// ID and token sizes for paste resources. Picked to give plenty of entropy -// without being painful to copy/paste manually. -const ( - // shareIDLen is the length, in characters, of a share's URL slug. - shareIDLen = 8 - // editTokenBytes is the random byte count behind an edit token. - // 32 bytes → 64 hex chars, matching the format used by `arc share` clients. - editTokenBytes = 32 - // eventIDRandBytes is the random tail appended to event IDs after the - // nanosecond timestamp prefix. - eventIDRandBytes = 12 -) - -// 64-bit nanosecond timestamps are split into 8 bytes by repeated >> 8 shifts; -// these constants name the high-order shift amounts to keep the byte build-up -// readable. -const ( - tsShift56 = 56 - tsShift48 = 48 - tsShift40 = 40 - tsShift32 = 32 - tsShift24 = 24 - tsShift16 = 16 - tsShift8 = 8 -) - -// Handlers holds the paste HTTP handler dependencies. -type Handlers struct { - store Storage -} - -// NewHandlers creates a new Handlers with the given storage backend. -func NewHandlers(s Storage) *Handlers { - return &Handlers{store: s} -} - -// Register mounts the paste endpoints on the provided echo.Group. -func (h *Handlers) Register(g *echo.Group) { - g.POST("", h.createPaste) - g.GET("/:id", h.getPaste) - g.PUT("/:id", h.updatePaste) - g.DELETE("/:id", h.deletePaste) - g.POST("/:id/blobs", h.appendEvent) -} - -// createPaste handles POST /. Creates a new share with a freshly minted ID -// and edit token. The edit token is returned to the caller exactly once — -// there is no recovery path if it's lost. -func (h *Handlers) createPaste(c echo.Context) error { - var req CreatePasteRequest - if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if len(req.PlanBlob) == 0 || len(req.PlanIV) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "plan_blob and plan_iv required") - } - id, err := newShareID() - if err != nil { - return err - } - token, err := newEditToken() - if err != nil { - return err - } - now := time.Now().UTC() - sh := Share{ - ID: id, - PlanBlob: req.PlanBlob, - PlanIV: req.PlanIV, - SchemaVer: req.SchemaVer, - CreatedAt: now, - UpdatedAt: now, - ExpiresAt: req.ExpiresAt, - } - if err := h.store.CreateShare(c.Request().Context(), sh, token); err != nil { - return err - } - return c.JSON(http.StatusCreated, CreatePasteResponse{ID: id, EditToken: token}) -} - -// getPaste handles GET /:id. Returns the share row plus its event log. -// Anonymous — no auth — since the encrypted blobs are already key-gated -// client-side via the URL fragment. -func (h *Handlers) getPaste(c echo.Context) error { - id := c.Param("id") - sh, err := h.store.GetShare(c.Request().Context(), id) - if err != nil { - if errors.Is(err, ErrShareNotFound) { - return echo.NewHTTPError(http.StatusNotFound, "not found") - } - return err - } - events, err := h.store.ListEvents(c.Request().Context(), id) - if err != nil { - return err - } - if events == nil { - events = []Event{} - } - return c.JSON(http.StatusOK, GetPasteResponse{Share: *sh, Events: events}) -} - -func (h *Handlers) updatePaste(c echo.Context) error { - id := c.Param("id") - token, err := bearerToken(c) - if err != nil { - return err - } - var req struct { - PlanBlob []byte `json:"plan_blob"` - PlanIV []byte `json:"plan_iv"` - } - if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if err := h.store.UpdateSharePlan(c.Request().Context(), id, req.PlanBlob, req.PlanIV, token); err != nil { - if errors.Is(err, ErrInvalidEditToken) { - return echo.NewHTTPError(http.StatusForbidden, "invalid edit token") - } - if errors.Is(err, ErrShareNotFound) { - return echo.NewHTTPError(http.StatusNotFound, "not found") - } - return err - } - return c.NoContent(http.StatusNoContent) -} - -func (h *Handlers) deletePaste(c echo.Context) error { - id := c.Param("id") - token, err := bearerToken(c) - if err != nil { - return err - } - if err := h.store.DeleteShare(c.Request().Context(), id, token); err != nil { - if errors.Is(err, ErrInvalidEditToken) { - return echo.NewHTTPError(http.StatusForbidden, "invalid edit token") - } - if errors.Is(err, ErrShareNotFound) { - return echo.NewHTTPError(http.StatusNotFound, "not found") - } - return err - } - return c.NoContent(http.StatusNoContent) -} - -func (h *Handlers) appendEvent(c echo.Context) error { - id := c.Param("id") - if _, err := h.store.GetShare(c.Request().Context(), id); err != nil { - if errors.Is(err, ErrShareNotFound) { - return echo.NewHTTPError(http.StatusNotFound, "not found") - } - return err - } - var req AppendEventRequest - if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if len(req.Blob) == 0 || len(req.IV) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "blob and iv required") - } - eventID, err := newEventID() - if err != nil { - return err - } - e := Event{ - ID: eventID, - ShareID: id, - Blob: req.Blob, - IV: req.IV, - CreatedAt: time.Now().UTC(), - } - if err := h.store.AppendEvent(c.Request().Context(), e); err != nil { - return err - } - return c.JSON(http.StatusCreated, map[string]string{"id": eventID}) -} - -// bearerToken extracts the Bearer token from the Authorization header. -func bearerToken(c echo.Context) (string, error) { - auth := c.Request().Header.Get("Authorization") - const prefix = "Bearer " - if !strings.HasPrefix(auth, prefix) { - return "", echo.NewHTTPError(http.StatusUnauthorized, "missing bearer token") - } - return strings.TrimPrefix(auth, prefix), nil -} - -// newShareID returns a Crockford base32 (lowercase, without i/l/o/u) ID. -func newShareID() (string, error) { - const alphabet = "0123456789abcdefghjkmnpqrstvwxyz" - buf := make([]byte, shareIDLen) - if _, err := rand.Read(buf); err != nil { - return "", err - } - out := make([]byte, shareIDLen) - for i := range out { - out[i] = alphabet[int(buf[i])%len(alphabet)] - } - return string(out), nil -} - -// newEditToken returns a hex-encoded random token (editTokenBytes random bytes). -func newEditToken() (string, error) { - buf := make([]byte, editTokenBytes) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return hex.EncodeToString(buf), nil -} - -// newEventID returns a time-prefixed random hex event ID. The nanosecond -// timestamp goes first so the event IDs sort lexicographically by creation -// time, which is convenient when scanning logs or storage. -func newEventID() (string, error) { - buf := make([]byte, eventIDRandBytes) - if _, err := rand.Read(buf); err != nil { - return "", err - } - ts := time.Now().UTC().UnixNano() - return hex.EncodeToString([]byte{ - byte(ts >> tsShift56), byte(ts >> tsShift48), byte(ts >> tsShift40), byte(ts >> tsShift32), - byte(ts >> tsShift24), byte(ts >> tsShift16), byte(ts >> tsShift8), byte(ts), - }) + hex.EncodeToString(buf), nil -} diff --git a/internal/paste/handlers_test.go b/internal/paste/handlers_test.go deleted file mode 100644 index 70e3e57..0000000 --- a/internal/paste/handlers_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package paste_test - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - _ "modernc.org/sqlite" - - "github.com/sentiolabs/arc/internal/paste" - "github.com/sentiolabs/arc/internal/paste/sqlite" -) - -func newTestServer(t *testing.T) *echo.Echo { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := sqlite.Apply(context.Background(), db); err != nil { - t.Fatalf("apply migrations: %v", err) - } - e := echo.New() - paste.NewHandlers(sqlite.New(db)).Register(e.Group("/api/paste")) - return e -} - -func TestCreatePaste(t *testing.T) { - e := newTestServer(t) - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: []byte{1, 2}, PlanIV: []byte{3, 4}, SchemaVer: 1}) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) - } - var resp paste.CreatePasteResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - if resp.ID == "" || resp.EditToken == "" { - t.Errorf("missing id or edit_token in response: %+v", resp) - } -} - -func TestCreatePasteEmptyBody(t *testing.T) { - e := newTestServer(t) - body, _ := json.Marshal(paste.CreatePasteRequest{SchemaVer: 1}) // no PlanBlob or PlanIV - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestGetPaste(t *testing.T) { - e := newTestServer(t) - - // Create a share first - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: []byte{1, 2}, PlanIV: []byte{3, 4}, SchemaVer: 1}) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("create failed with %d: %s", rec.Code, rec.Body.String()) - } - var created paste.CreatePasteResponse - _ = json.Unmarshal(rec.Body.Bytes(), &created) - - // GET the share - req2 := httptest.NewRequest(http.MethodGet, "/api/paste/"+created.ID, nil) - rec2 := httptest.NewRecorder() - e.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec2.Code, rec2.Body.String()) - } - var got paste.GetPasteResponse - if err := json.Unmarshal(rec2.Body.Bytes(), &got); err != nil { - t.Fatalf("unmarshal get response: %v", err) - } - if got.ID != created.ID { - t.Errorf("expected id %q, got %q", created.ID, got.ID) - } - // got.Events may be nil for an empty event log — that's fine, we just - // want to confirm the unmarshal didn't blow up above. - _ = got.Events -} - -func TestGetPasteNotFound(t *testing.T) { - e := newTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/api/paste/doesnotexist", nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestUpdatePasteWithToken(t *testing.T) { - e := newTestServer(t) - - // Create - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: []byte{1}, PlanIV: []byte{2}, SchemaVer: 1}) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - var created paste.CreatePasteResponse - _ = json.Unmarshal(rec.Body.Bytes(), &created) - - // Update with correct token - upd, _ := json.Marshal(map[string]any{"plan_blob": []byte{9}, "plan_iv": []byte{8}}) - req2 := httptest.NewRequest(http.MethodPut, "/api/paste/"+created.ID, bytes.NewReader(upd)) - req2.Header.Set("Content-Type", "application/json") - req2.Header.Set("Authorization", "Bearer "+created.EditToken) - rec2 := httptest.NewRecorder() - e.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusNoContent { - t.Errorf("expected 204, got %d: %s", rec2.Code, rec2.Body.String()) - } -} - -func TestUpdatePasteWrongToken(t *testing.T) { - e := newTestServer(t) - - // Create - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: []byte{1}, PlanIV: []byte{2}, SchemaVer: 1}) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - var created paste.CreatePasteResponse - _ = json.Unmarshal(rec.Body.Bytes(), &created) - - // Update with wrong token - upd, _ := json.Marshal(map[string]any{"plan_blob": []byte{9}, "plan_iv": []byte{8}}) - req2 := httptest.NewRequest(http.MethodPut, "/api/paste/"+created.ID, bytes.NewReader(upd)) - req2.Header.Set("Content-Type", "application/json") - req2.Header.Set("Authorization", "Bearer wrongtoken") - rec2 := httptest.NewRecorder() - e.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusForbidden { - t.Errorf("expected 403, got %d: %s", rec2.Code, rec2.Body.String()) - } -} - -func TestUpdatePasteMissingAuth(t *testing.T) { - e := newTestServer(t) - - // Create - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: []byte{1}, PlanIV: []byte{2}, SchemaVer: 1}) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - var created paste.CreatePasteResponse - _ = json.Unmarshal(rec.Body.Bytes(), &created) - - // Update with no Authorization header - upd, _ := json.Marshal(map[string]any{"plan_blob": []byte{9}, "plan_iv": []byte{8}}) - req2 := httptest.NewRequest(http.MethodPut, "/api/paste/"+created.ID, bytes.NewReader(upd)) - req2.Header.Set("Content-Type", "application/json") - rec2 := httptest.NewRecorder() - e.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusUnauthorized { - t.Errorf("expected 401, got %d: %s", rec2.Code, rec2.Body.String()) - } -} - -func TestDeletePasteWithToken(t *testing.T) { - e := newTestServer(t) - - // Create - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: []byte{1}, PlanIV: []byte{2}, SchemaVer: 1}) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - var created paste.CreatePasteResponse - _ = json.Unmarshal(rec.Body.Bytes(), &created) - - // Delete with correct token - req2 := httptest.NewRequest(http.MethodDelete, "/api/paste/"+created.ID, nil) - req2.Header.Set("Authorization", "Bearer "+created.EditToken) - rec2 := httptest.NewRecorder() - e.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusNoContent { - t.Errorf("expected 204, got %d: %s", rec2.Code, rec2.Body.String()) - } -} - -func TestAppendEvent(t *testing.T) { - e := newTestServer(t) - - // Create share - body, _ := json.Marshal(paste.CreatePasteRequest{PlanBlob: []byte{1, 2}, PlanIV: []byte{3, 4}, SchemaVer: 1}) - req := httptest.NewRequest(http.MethodPost, "/api/paste", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - var created paste.CreatePasteResponse - _ = json.Unmarshal(rec.Body.Bytes(), &created) - - // Append event - evBody, _ := json.Marshal(paste.AppendEventRequest{Blob: []byte{5, 6}, IV: []byte{7, 8}}) - req2 := httptest.NewRequest(http.MethodPost, "/api/paste/"+created.ID+"/blobs", bytes.NewReader(evBody)) - req2.Header.Set("Content-Type", "application/json") - rec2 := httptest.NewRecorder() - e.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rec2.Code, rec2.Body.String()) - } - var evResp map[string]string - _ = json.Unmarshal(rec2.Body.Bytes(), &evResp) - if evResp["id"] == "" { - t.Errorf("expected event id in response: %+v", evResp) - } - - // GET shows the event - req3 := httptest.NewRequest(http.MethodGet, "/api/paste/"+created.ID, nil) - rec3 := httptest.NewRecorder() - e.ServeHTTP(rec3, req3) - var got paste.GetPasteResponse - _ = json.Unmarshal(rec3.Body.Bytes(), &got) - if len(got.Events) != 1 { - t.Errorf("expected 1 event, got %d", len(got.Events)) - } -} - -func TestAppendEventToMissingShare(t *testing.T) { - e := newTestServer(t) - evBody, _ := json.Marshal(paste.AppendEventRequest{Blob: []byte{5, 6}, IV: []byte{7, 8}}) - req := httptest.NewRequest(http.MethodPost, "/api/paste/doesnotexist/blobs", bytes.NewReader(evBody)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d: %s", rec.Code, rec.Body.String()) - } -} diff --git a/internal/paste/sqlite/migrations.go b/internal/paste/sqlite/migrations.go deleted file mode 100644 index 162d1e9..0000000 --- a/internal/paste/sqlite/migrations.go +++ /dev/null @@ -1,70 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "embed" - "errors" - "fmt" - "io/fs" - "sort" - "strings" -) - -// migrationsFS holds the embedded *.sql files. Each file is one migration; -// they are applied in filename-sorted order, and the names are recorded in -// the paste_migrations table so we never re-run one. -// -//go:embed migrations/*.sql -var migrationsFS embed.FS - -// Apply runs every embedded migration that hasn't already been recorded in -// paste_migrations, in lexicographic filename order. Idempotent — safe to call -// on every server boot. -func Apply(ctx context.Context, db *sql.DB) error { - // The bookkeeping table itself uses CREATE IF NOT EXISTS rather than a - // migration file so we have somewhere to write the first migration's - // completion record. - if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS paste_migrations ( - name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil { - return fmt.Errorf("create paste_migrations: %w", err) - } - entries, err := fs.ReadDir(migrationsFS, "migrations") - if err != nil { - return err - } - // Filter to .sql, sort lexicographically — naming convention: - // 0001_*.sql, 0002_*.sql, ... ensures the order is also chronological. - names := make([]string, 0, len(entries)) - for _, e := range entries { - if !strings.HasSuffix(e.Name(), ".sql") { - continue - } - names = append(names, e.Name()) - } - sort.Strings(names) - for _, name := range names { - // Skip migrations we've already applied — `name` is the primary key - // in paste_migrations, so a successful Scan means we're done. - var existing string - err := db.QueryRowContext(ctx, `SELECT name FROM paste_migrations WHERE name = ?`, name).Scan(&existing) - if err == nil { - continue - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - body, err := migrationsFS.ReadFile("migrations/" + name) - if err != nil { - return err - } - if _, err := db.ExecContext(ctx, string(body)); err != nil { - return fmt.Errorf("apply %s: %w", name, err) - } - // Record the completion so the next boot skips this file. - if _, err := db.ExecContext(ctx, `INSERT INTO paste_migrations(name) VALUES (?)`, name); err != nil { - return err - } - } - return nil -} diff --git a/internal/paste/sqlite/migrations/001_init.sql b/internal/paste/sqlite/migrations/001_init.sql deleted file mode 100644 index 9c476bb..0000000 --- a/internal/paste/sqlite/migrations/001_init.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE paste_shares ( - id TEXT PRIMARY KEY, - edit_token TEXT NOT NULL, - plan_blob BLOB NOT NULL, - plan_iv BLOB NOT NULL, - schema_ver INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP -); - -CREATE TABLE paste_events ( - id TEXT PRIMARY KEY, - share_id TEXT NOT NULL REFERENCES paste_shares(id) ON DELETE CASCADE, - blob BLOB NOT NULL, - iv BLOB NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_paste_events_share ON paste_events(share_id, created_at); diff --git a/internal/paste/sqlite/store.go b/internal/paste/sqlite/store.go deleted file mode 100644 index f92ec88..0000000 --- a/internal/paste/sqlite/store.go +++ /dev/null @@ -1,133 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "time" - - "github.com/sentiolabs/arc/internal/paste" -) - -// Store is the SQLite-backed paste.Storage implementation. -type Store struct { - db *sql.DB -} - -// New wraps an open *sql.DB as a Store. The caller still owns the connection -// and is responsible for closing it. -func New(db *sql.DB) *Store { return &Store{db: db} } - -// CreateShare inserts a new share row plus its edit token. The token is -// stored in the same row so the table is the source of truth for both the -// public ID and the bearer credential needed to mutate it later. -func (s *Store) CreateShare(ctx context.Context, share paste.Share, editToken string) error { - _, err := s.db.ExecContext(ctx, - `INSERT INTO paste_shares (id, edit_token, plan_blob, plan_iv, schema_ver, created_at, updated_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - share.ID, editToken, share.PlanBlob, share.PlanIV, share.SchemaVer, - share.CreatedAt, share.UpdatedAt, share.ExpiresAt, - ) - return err -} - -// GetShare returns the share row by ID. The edit_token column is intentionally -// excluded from the SELECT — only VerifyEditToken consults it, so untrusted -// reads can't accidentally leak the token via an error or log. -func (s *Store) GetShare(ctx context.Context, id string) (*paste.Share, error) { - var sh paste.Share - var expires sql.NullTime - err := s.db.QueryRowContext(ctx, - `SELECT id, plan_blob, plan_iv, schema_ver, created_at, updated_at, expires_at - FROM paste_shares WHERE id = ?`, id). - Scan(&sh.ID, &sh.PlanBlob, &sh.PlanIV, &sh.SchemaVer, &sh.CreatedAt, &sh.UpdatedAt, &expires) - if errors.Is(err, sql.ErrNoRows) { - return nil, paste.ErrShareNotFound - } - if err != nil { - return nil, err - } - if expires.Valid { - t := expires.Time - sh.ExpiresAt = &t - } - return &sh, nil -} - -// UpdateSharePlan replaces the encrypted plan blob & nonce after verifying -// the edit token. updated_at is bumped so clients can detect changes. -func (s *Store) UpdateSharePlan(ctx context.Context, id string, planBlob, iv []byte, editToken string) error { - ok, err := s.VerifyEditToken(ctx, id, editToken) - if err != nil { - return err - } - if !ok { - return paste.ErrInvalidEditToken - } - _, err = s.db.ExecContext(ctx, - `UPDATE paste_shares SET plan_blob = ?, plan_iv = ?, updated_at = ? WHERE id = ?`, - planBlob, iv, time.Now(), id) - return err -} - -// DeleteShare removes the share (and, via cascade, its event log) after -// verifying the edit token. -func (s *Store) DeleteShare(ctx context.Context, id, editToken string) error { - ok, err := s.VerifyEditToken(ctx, id, editToken) - if err != nil { - return err - } - if !ok { - return paste.ErrInvalidEditToken - } - _, err = s.db.ExecContext(ctx, `DELETE FROM paste_shares WHERE id = ?`, id) - return err -} - -// AppendEvent appends a new event row to the share's append-only log. Events -// are only ever inserted — never updated or deleted — so the entire history -// is reproducible by replay. -func (s *Store) AppendEvent(ctx context.Context, e paste.Event) error { - _, err := s.db.ExecContext(ctx, - `INSERT INTO paste_events (id, share_id, blob, iv, created_at) VALUES (?, ?, ?, ?, ?)`, - e.ID, e.ShareID, e.Blob, e.IV, e.CreatedAt) - return err -} - -// ListEvents returns every event for the share, ordered by created_at then -// id so callers can replay state deterministically (Go map iteration is -// randomized, so the deterministic order has to come from the SQL side). -func (s *Store) ListEvents(ctx context.Context, shareID string) ([]paste.Event, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT id, share_id, blob, iv, created_at FROM paste_events - WHERE share_id = ? ORDER BY created_at ASC, id ASC`, shareID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []paste.Event - for rows.Next() { - var e paste.Event - if err := rows.Scan(&e.ID, &e.ShareID, &e.Blob, &e.IV, &e.CreatedAt); err != nil { - return nil, err - } - out = append(out, e) - } - return out, rows.Err() -} - -// VerifyEditToken checks token against the stored edit_token for share id. -// Returns (false, ErrShareNotFound) when the share doesn't exist; (false, nil) -// when the share exists but the token doesn't match; (true, nil) on success. -func (s *Store) VerifyEditToken(ctx context.Context, id, token string) (bool, error) { - var stored string - err := s.db.QueryRowContext(ctx, - `SELECT edit_token FROM paste_shares WHERE id = ?`, id).Scan(&stored) - if errors.Is(err, sql.ErrNoRows) { - return false, paste.ErrShareNotFound - } - if err != nil { - return false, err - } - return stored == token, nil -} diff --git a/internal/paste/sqlite/store_test.go b/internal/paste/sqlite/store_test.go deleted file mode 100644 index 5b43c3a..0000000 --- a/internal/paste/sqlite/store_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package sqlite_test - -import ( - "context" - "database/sql" - "errors" - "testing" - "time" - - _ "modernc.org/sqlite" - - "github.com/sentiolabs/arc/internal/paste" - "github.com/sentiolabs/arc/internal/paste/sqlite" -) - -var _ paste.Storage = (*sqlite.Store)(nil) - -func newTestStore(t *testing.T) *sqlite.Store { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - if err := sqlite.Apply(context.Background(), db); err != nil { - t.Fatal(err) - } - return sqlite.New(db) -} - -func TestCreateAndGetShare(t *testing.T) { - s := newTestStore(t) - now := time.Now().UTC().Truncate(time.Second) - share := paste.Share{ - ID: "abc12345", - PlanBlob: []byte{1, 2, 3}, - PlanIV: []byte{4, 5, 6}, - SchemaVer: 1, - CreatedAt: now, - UpdatedAt: now, - } - if err := s.CreateShare(context.Background(), share, "tok"); err != nil { - t.Fatalf("CreateShare: %v", err) - } - got, err := s.GetShare(context.Background(), "abc12345") - if err != nil { - t.Fatalf("GetShare: %v", err) - } - if got.ID != share.ID || string(got.PlanBlob) != string(share.PlanBlob) { - t.Errorf("got %+v, want %+v", got, share) - } -} - -func TestVerifyEditToken(t *testing.T) { - s := newTestStore(t) - now := time.Now().UTC() - _ = s.CreateShare(context.Background(), paste.Share{ - ID: "x", - PlanBlob: []byte{0}, - PlanIV: []byte{0}, - SchemaVer: 1, - CreatedAt: now, - UpdatedAt: now, - }, "good") - ok, err := s.VerifyEditToken(context.Background(), "x", "good") - if err != nil || !ok { - t.Errorf("expected good token to verify, got ok=%v err=%v", ok, err) - } - ok, _ = s.VerifyEditToken(context.Background(), "x", "bad") - if ok { - t.Error("expected bad token to fail verify") - } -} - -func TestUpdateSharePlanRequiresToken(t *testing.T) { - s := newTestStore(t) - now := time.Now().UTC() - _ = s.CreateShare(context.Background(), paste.Share{ - ID: "x", - PlanBlob: []byte{0}, - PlanIV: []byte{0}, - SchemaVer: 1, - CreatedAt: now, - UpdatedAt: now, - }, "good") - err := s.UpdateSharePlan(context.Background(), "x", []byte{9}, []byte{8}, "bad") - if !errors.Is(err, paste.ErrInvalidEditToken) { - t.Errorf("expected ErrInvalidEditToken, got %v", err) - } -} - -func TestAppendAndListEvents(t *testing.T) { - s := newTestStore(t) - now := time.Now().UTC() - _ = s.CreateShare(context.Background(), paste.Share{ - ID: "x", - PlanBlob: []byte{0}, - PlanIV: []byte{0}, - SchemaVer: 1, - CreatedAt: now, - UpdatedAt: now, - }, "tok") - _ = s.AppendEvent(context.Background(), paste.Event{ - ID: "e1", - ShareID: "x", - Blob: []byte{1}, - IV: []byte{1}, - CreatedAt: now, - }) - _ = s.AppendEvent(context.Background(), paste.Event{ - ID: "e2", - ShareID: "x", - Blob: []byte{2}, - IV: []byte{2}, - CreatedAt: now.Add(time.Second), - }) - events, err := s.ListEvents(context.Background(), "x") - if err != nil || len(events) != 2 { - t.Fatalf("expected 2 events, got %d (err=%v)", len(events), err) - } - if events[0].ID != "e1" { - t.Errorf("expected ordering by created_at; first event was %s", events[0].ID) - } -} diff --git a/internal/paste/storage.go b/internal/paste/storage.go deleted file mode 100644 index 5a8fdc1..0000000 --- a/internal/paste/storage.go +++ /dev/null @@ -1,29 +0,0 @@ -package paste - -import ( - "context" - "errors" -) - -// Sentinel errors returned by Storage implementations. Handlers translate -// these into HTTP status codes; CLI clients pattern-match on them too. -var ( - // ErrShareNotFound is returned when no paste share exists with the given ID. - ErrShareNotFound = errors.New("paste share not found") - // ErrInvalidEditToken is returned when an edit/delete request's bearer - // token doesn't match the share's stored edit token. - ErrInvalidEditToken = errors.New("invalid edit token") -) - -// Storage is the persistence interface backing the paste service. It captures -// the full lifecycle of a share (create, read, update plan, delete) plus the -// append-only event log used for review comments. -type Storage interface { - CreateShare(ctx context.Context, s Share, editToken string) error - GetShare(ctx context.Context, id string) (*Share, error) - UpdateSharePlan(ctx context.Context, id string, planBlob, iv []byte, editToken string) error - DeleteShare(ctx context.Context, id string, editToken string) error - AppendEvent(ctx context.Context, e Event) error - ListEvents(ctx context.Context, shareID string) ([]Event, error) - VerifyEditToken(ctx context.Context, id, token string) (bool, error) -} diff --git a/internal/paste/testdata/README.md b/internal/paste/testdata/README.md deleted file mode 100644 index 913aefb..0000000 --- a/internal/paste/testdata/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Crypto cross-language fixtures - -`xlang_fixtures.json` contains AES-256-GCM ciphertexts produced by the Go -implementation in `internal/paste/crypto.go`. The TypeScript test -`web/src/lib/paste/crypto.xlang.test.ts` reads these fixtures and verifies -that the JS Web Crypto API decrypts them to the original plaintext. - -## Regenerate - - go run ./internal/paste/cmd/genxlang/ - -This overwrites the JSON file with fresh ciphertexts (random keys + IVs each -run). Don't regenerate casually — the goal is for both Go and TS tests to -pass against the same checked-in fixtures, so a regen invalidates the -TS-side check until you also re-run it. diff --git a/internal/paste/testdata/xlang_fixtures.json b/internal/paste/testdata/xlang_fixtures.json deleted file mode 100644 index 7565d6f..0000000 --- a/internal/paste/testdata/xlang_fixtures.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "name": "simple-string", - "key_b64url": "0sPOEgk6sXnLa8gG-Us0rGbFVzXtZLdDz05i_Ht7Y8s", - "plaintext": "hello world", - "ciphertext_b64": "xEZxsBqDhxxzVrszzoJf8JCO+VK/vo5sDrIK3sY=", - "iv_b64": "59bzfpIIYaB2YpOG" - }, - { - "name": "empty-object", - "key_b64url": "XcTZkGxWCrrxaY_h3NiCj6lpYuNXKUeW6biLnisFsUw", - "plaintext": {}, - "ciphertext_b64": "MGA2OLHstGPKAq880FJ7v1Dr", - "iv_b64": "aQBdDtz/ZeNHGA0i" - }, - { - "name": "nested-object", - "key_b64url": "iyo2Rc7qGRKTKZz895k2d3Yp8zxa6J6pYWlO1_Nty4Q", - "plaintext": { - "anchor": { - "line_end": 1, - "line_start": 1, - "quoted_text": "x" - }, - "author_name": "Alice", - "id": "c1", - "kind": "comment" - }, - "ciphertext_b64": "Zron1CbqnlsZaSJWZezFn7TD7rUwWoMNT/MuzCjm31hjmbBoZ1OQB/6InUuL02KFcbvTtgrmH18ryHXluI3xmyfV65NuaGN6dIm+y7LZP1bykPm7QH7qBAxc7XekG+Zj4fcGHbPh8RA0IbTJ/PhEFhvj4Hqr/hbOZ+go", - "iv_b64": "2gljqJMdKMtG2S28" - } -] \ No newline at end of file diff --git a/internal/paste/types.go b/internal/paste/types.go deleted file mode 100644 index b0f0f2c..0000000 --- a/internal/paste/types.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package paste provides zero-knowledge encrypted paste storage for arc plans -// and review comments. The server stores opaque ciphertext blobs; encryption -// and decryption happen exclusively on clients. -package paste - -import "time" - -type Share struct { - ID string `json:"id"` - PlanBlob []byte `json:"plan_blob"` - PlanIV []byte `json:"plan_iv"` - SchemaVer int `json:"schema_ver"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` -} - -type Event struct { - ID string `json:"id"` - ShareID string `json:"share_id"` - Blob []byte `json:"blob"` - IV []byte `json:"iv"` - CreatedAt time.Time `json:"created_at"` -} - -type CreatePasteRequest struct { - PlanBlob []byte `json:"plan_blob"` - PlanIV []byte `json:"plan_iv"` - SchemaVer int `json:"schema_ver"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` -} - -type CreatePasteResponse struct { - ID string `json:"id"` - EditToken string `json:"edit_token"` -} - -type AppendEventRequest struct { - Blob []byte `json:"blob"` - IV []byte `json:"iv"` -} - -type GetPasteResponse struct { - Share - Events []Event `json:"events"` -} diff --git a/internal/paste/types_test.go b/internal/paste/types_test.go deleted file mode 100644 index deeb1c0..0000000 --- a/internal/paste/types_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package paste_test - -import ( - "testing" - - "github.com/sentiolabs/arc/internal/paste" -) - -func TestShareContract(t *testing.T) { - var s paste.Share - _ = s.ID - _ = s.PlanBlob - _ = s.PlanIV - _ = s.SchemaVer - _ = s.CreatedAt - _ = s.UpdatedAt - _ = s.ExpiresAt -} - -func TestEventContract(t *testing.T) { - var e paste.Event - _ = e.ID - _ = e.ShareID - _ = e.Blob - _ = e.IV - _ = e.CreatedAt -} - -func TestCreatePasteResponseContract(t *testing.T) { - var r paste.CreatePasteResponse - _ = r.ID - _ = r.EditToken -} diff --git a/internal/storage/sqlite/db/models.go b/internal/storage/sqlite/db/models.go index 6cd7085..4ccb3f7 100644 --- a/internal/storage/sqlite/db/models.go +++ b/internal/storage/sqlite/db/models.go @@ -135,16 +135,6 @@ type Project struct { UpdatedAt time.Time `json:"updated_at"` } -type Share struct { - ID string `json:"id"` - Kind string `json:"kind"` - Url string `json:"url"` - KeyB64url string `json:"key_b64url"` - EditToken string `json:"edit_token"` - PlanFile sql.NullString `json:"plan_file"` - CreatedAt time.Time `json:"created_at"` -} - type Workspace struct { ID string `json:"id"` ProjectID string `json:"project_id"` diff --git a/internal/storage/sqlite/db/queries/shares.sql b/internal/storage/sqlite/db/queries/shares.sql deleted file mode 100644 index 3cfd2ea..0000000 --- a/internal/storage/sqlite/db/queries/shares.sql +++ /dev/null @@ -1,19 +0,0 @@ --- name: UpsertShare :exec -INSERT INTO shares (id, kind, url, key_b64url, edit_token, plan_file, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(id) DO UPDATE SET - kind = excluded.kind, - url = excluded.url, - key_b64url = excluded.key_b64url, - edit_token = excluded.edit_token, - plan_file = excluded.plan_file, - created_at = excluded.created_at; - --- name: GetShare :one -SELECT * FROM shares WHERE id = ?; - --- name: ListShares :many -SELECT * FROM shares ORDER BY created_at DESC; - --- name: DeleteShare :exec -DELETE FROM shares WHERE id = ?; diff --git a/internal/storage/sqlite/db/schema.sql b/internal/storage/sqlite/db/schema.sql index 3b735e5..905637c 100644 --- a/internal/storage/sqlite/db/schema.sql +++ b/internal/storage/sqlite/db/schema.sql @@ -197,16 +197,3 @@ CREATE TABLE ai_agents ( ); CREATE INDEX idx_ai_agents_session ON ai_agents(session_id); - --- Shares (author-side keyring) -CREATE TABLE shares ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL CHECK (kind IN ('local', 'shared')), - url TEXT NOT NULL, - key_b64url TEXT NOT NULL, - edit_token TEXT NOT NULL, - plan_file TEXT, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_shares_created_at ON shares(created_at DESC); diff --git a/internal/storage/sqlite/db/shares.sql.go b/internal/storage/sqlite/db/shares.sql.go deleted file mode 100644 index 03c325a..0000000 --- a/internal/storage/sqlite/db/shares.sql.go +++ /dev/null @@ -1,110 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: shares.sql - -package db - -import ( - "context" - "database/sql" - "time" -) - -const deleteShare = `-- name: DeleteShare :exec -DELETE FROM shares WHERE id = ? -` - -func (q *Queries) DeleteShare(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteShare, id) - return err -} - -const getShare = `-- name: GetShare :one -SELECT id, kind, url, key_b64url, edit_token, plan_file, created_at FROM shares WHERE id = ? -` - -func (q *Queries) GetShare(ctx context.Context, id string) (*Share, error) { - row := q.db.QueryRowContext(ctx, getShare, id) - var i Share - err := row.Scan( - &i.ID, - &i.Kind, - &i.Url, - &i.KeyB64url, - &i.EditToken, - &i.PlanFile, - &i.CreatedAt, - ) - return &i, err -} - -const listShares = `-- name: ListShares :many -SELECT id, kind, url, key_b64url, edit_token, plan_file, created_at FROM shares ORDER BY created_at DESC -` - -func (q *Queries) ListShares(ctx context.Context) ([]*Share, error) { - rows, err := q.db.QueryContext(ctx, listShares) - if err != nil { - return nil, err - } - defer rows.Close() - items := []*Share{} - for rows.Next() { - var i Share - if err := rows.Scan( - &i.ID, - &i.Kind, - &i.Url, - &i.KeyB64url, - &i.EditToken, - &i.PlanFile, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, &i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const upsertShare = `-- name: UpsertShare :exec -INSERT INTO shares (id, kind, url, key_b64url, edit_token, plan_file, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(id) DO UPDATE SET - kind = excluded.kind, - url = excluded.url, - key_b64url = excluded.key_b64url, - edit_token = excluded.edit_token, - plan_file = excluded.plan_file, - created_at = excluded.created_at -` - -type UpsertShareParams struct { - ID string `json:"id"` - Kind string `json:"kind"` - Url string `json:"url"` - KeyB64url string `json:"key_b64url"` - EditToken string `json:"edit_token"` - PlanFile sql.NullString `json:"plan_file"` - CreatedAt time.Time `json:"created_at"` -} - -func (q *Queries) UpsertShare(ctx context.Context, arg UpsertShareParams) error { - _, err := q.db.ExecContext(ctx, upsertShare, - arg.ID, - arg.Kind, - arg.Url, - arg.KeyB64url, - arg.EditToken, - arg.PlanFile, - arg.CreatedAt, - ) - return err -} diff --git a/internal/storage/sqlite/shares.go b/internal/storage/sqlite/shares.go deleted file mode 100644 index 81895ff..0000000 --- a/internal/storage/sqlite/shares.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package sqlite implements the storage interface using SQLite. -// This file handles share keyring operations. -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/sentiolabs/arc/internal/storage" - "github.com/sentiolabs/arc/internal/storage/sqlite/db" - "github.com/sentiolabs/arc/internal/types" -) - -// UpsertShare inserts or replaces a share record. Stamps CreatedAt to now -// when callers omit it, so HTTP handlers and the legacy import path don't -// each need their own default. -func (s *Store) UpsertShare(ctx context.Context, share *types.Share) error { - return s.upsertShareWith(ctx, s.queries, share) -} - -// UpsertShares atomically upserts a batch of shares in a single transaction. -// All-or-nothing: a validation or constraint failure on any entry rolls the -// whole batch back so callers can fix the bad entry and retry without first -// having to clean up partial state. -func (s *Store) UpsertShares(ctx context.Context, shares []*types.Share) error { - if len(shares) == 0 { - return nil - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin transaction: %w", err) - } - defer tx.Rollback() //nolint:errcheck - - qtx := s.queries.WithTx(tx) - for _, share := range shares { - if err := s.upsertShareWith(ctx, qtx, share); err != nil { - return err - } - } - return tx.Commit() -} - -// upsertShareWith is the shared body of UpsertShare and UpsertShares. The qtx -// argument lets the caller pick between the bare queries and a transactional -// view (queries.WithTx). -func (s *Store) upsertShareWith(ctx context.Context, qtx *db.Queries, share *types.Share) error { - if share.CreatedAt.IsZero() { - share.CreatedAt = time.Now().UTC() - } - if err := share.Validate(); err != nil { - return fmt.Errorf("upsert share: %w", err) - } - err := qtx.UpsertShare(ctx, db.UpsertShareParams{ - ID: share.ID, - Kind: string(share.Kind), - Url: share.URL, - KeyB64url: share.KeyB64Url, - EditToken: share.EditToken, - PlanFile: toNullString(share.PlanFile), - CreatedAt: share.CreatedAt.UTC(), - }) - if err != nil { - return fmt.Errorf("upsert share: %w", err) - } - return nil -} - -// GetShare retrieves a share by ID. -// Returns storage.ErrShareNotFound if the ID does not exist. -func (s *Store) GetShare(ctx context.Context, id string) (*types.Share, error) { - row, err := s.queries.GetShare(ctx, id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, storage.ErrShareNotFound - } - return nil, fmt.Errorf("get share: %w", err) - } - return rowToShare(row), nil -} - -// ListShares returns all shares ordered by created_at DESC (newest first). -func (s *Store) ListShares(ctx context.Context) ([]*types.Share, error) { - rows, err := s.queries.ListShares(ctx) - if err != nil { - return nil, fmt.Errorf("list shares: %w", err) - } - out := make([]*types.Share, len(rows)) - for i, r := range rows { - out[i] = rowToShare(r) - } - return out, nil -} - -// DeleteShare removes a share by ID. -// Idempotent: no error is returned if the ID does not exist. -func (s *Store) DeleteShare(ctx context.Context, id string) error { - if err := s.queries.DeleteShare(ctx, id); err != nil { - return fmt.Errorf("delete share: %w", err) - } - return nil -} - -// rowToShare converts a db.Share row to a types.Share. -func rowToShare(r *db.Share) *types.Share { - return &types.Share{ - ID: r.ID, - Kind: types.ShareKind(r.Kind), - URL: r.Url, - KeyB64Url: r.KeyB64url, - EditToken: r.EditToken, - PlanFile: fromNullString(r.PlanFile), - CreatedAt: r.CreatedAt.UTC(), - } -} diff --git a/internal/storage/sqlite/shares_test.go b/internal/storage/sqlite/shares_test.go deleted file mode 100644 index 30950af..0000000 --- a/internal/storage/sqlite/shares_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package sqlite_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/sentiolabs/arc/internal/storage" - "github.com/sentiolabs/arc/internal/types" -) - -func makeTestShare(id string) *types.Share { - return &types.Share{ - ID: id, - Kind: types.ShareKindLocal, - URL: "https://example.com/paste/" + id, - KeyB64Url: "dGVzdGtleQ==", - EditToken: "edit-token-" + id, - CreatedAt: time.Now().UTC().Truncate(time.Second), - } -} - -func TestUpsertShare_Insert(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - share := makeTestShare("share-insert-1") - - err := store.UpsertShare(ctx, share) - if err != nil { - t.Fatalf("UpsertShare() error = %v", err) - } - - got, err := store.GetShare(ctx, share.ID) - if err != nil { - t.Fatalf("GetShare() after insert error = %v", err) - } - - if got.ID != share.ID { - t.Errorf("ID = %q, want %q", got.ID, share.ID) - } - if got.Kind != share.Kind { - t.Errorf("Kind = %q, want %q", got.Kind, share.Kind) - } - if got.URL != share.URL { - t.Errorf("URL = %q, want %q", got.URL, share.URL) - } - if got.KeyB64Url != share.KeyB64Url { - t.Errorf("KeyB64Url = %q, want %q", got.KeyB64Url, share.KeyB64Url) - } - if got.EditToken != share.EditToken { - t.Errorf("EditToken = %q, want %q", got.EditToken, share.EditToken) - } - if got.PlanFile != share.PlanFile { - t.Errorf("PlanFile = %q, want %q", got.PlanFile, share.PlanFile) - } - if !got.CreatedAt.Equal(share.CreatedAt) { - t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, share.CreatedAt) - } -} - -func TestUpsertShare_Replace(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - share := makeTestShare("share-replace-1") - - // Insert first - if err := store.UpsertShare(ctx, share); err != nil { - t.Fatalf("UpsertShare() first insert error = %v", err) - } - - // Update the same ID with different fields - updated := &types.Share{ - ID: share.ID, - Kind: types.ShareKindShared, - URL: "https://example.com/paste/updated", - KeyB64Url: "dXBkYXRlZGtleQ==", - EditToken: "updated-edit-token", - PlanFile: "/path/to/plan.md", - CreatedAt: share.CreatedAt.Add(time.Minute), - } - if err := store.UpsertShare(ctx, updated); err != nil { - t.Fatalf("UpsertShare() second upsert error = %v", err) - } - - // GetShare should return the second version - got, err := store.GetShare(ctx, share.ID) - if err != nil { - t.Fatalf("GetShare() after upsert error = %v", err) - } - - if got.Kind != updated.Kind { - t.Errorf("Kind = %q, want %q", got.Kind, updated.Kind) - } - if got.URL != updated.URL { - t.Errorf("URL = %q, want %q", got.URL, updated.URL) - } - if got.KeyB64Url != updated.KeyB64Url { - t.Errorf("KeyB64Url = %q, want %q", got.KeyB64Url, updated.KeyB64Url) - } - if got.EditToken != updated.EditToken { - t.Errorf("EditToken = %q, want %q", got.EditToken, updated.EditToken) - } - if got.PlanFile != updated.PlanFile { - t.Errorf("PlanFile = %q, want %q", got.PlanFile, updated.PlanFile) - } -} - -func TestUpsertShare_ValidationError(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - - // Empty ID should fail validation - invalid := &types.Share{ - ID: "", - Kind: types.ShareKindLocal, - URL: "https://example.com/paste/x", - KeyB64Url: "dGVzdA==", - EditToken: "tok", - } - err := store.UpsertShare(ctx, invalid) - if err == nil { - t.Fatal("UpsertShare() expected error for empty ID, got nil") - } -} - -func TestGetShare_Found(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - share := makeTestShare("share-get-1") - - if err := store.UpsertShare(ctx, share); err != nil { - t.Fatalf("UpsertShare() error = %v", err) - } - - got, err := store.GetShare(ctx, share.ID) - if err != nil { - t.Fatalf("GetShare() error = %v", err) - } - if got.ID != share.ID { - t.Errorf("ID = %q, want %q", got.ID, share.ID) - } -} - -func TestGetShare_NotFound(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - - _, err := store.GetShare(ctx, "nonexistent-share-id") - if err == nil { - t.Fatal("GetShare() expected error for missing ID, got nil") - } - if !errors.Is(err, storage.ErrShareNotFound) { - t.Errorf("GetShare() error = %v, want errors.Is(err, storage.ErrShareNotFound) to be true", err) - } -} - -func TestListShares_OrderedByCreatedAtDesc(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - - baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - - older := &types.Share{ - ID: "share-older", - Kind: types.ShareKindLocal, - URL: "https://example.com/paste/older", - KeyB64Url: "b2xkZXJrZXk=", - EditToken: "edit-older", - CreatedAt: baseTime, - } - newer := &types.Share{ - ID: "share-newer", - Kind: types.ShareKindShared, - URL: "https://example.com/paste/newer", - KeyB64Url: "bmV3ZXJrZXk=", - EditToken: "edit-newer", - CreatedAt: baseTime.Add(time.Hour), - } - - // Insert older first, then newer - if err := store.UpsertShare(ctx, older); err != nil { - t.Fatalf("UpsertShare(older) error = %v", err) - } - if err := store.UpsertShare(ctx, newer); err != nil { - t.Fatalf("UpsertShare(newer) error = %v", err) - } - - list, err := store.ListShares(ctx) - if err != nil { - t.Fatalf("ListShares() error = %v", err) - } - if len(list) != 2 { - t.Fatalf("ListShares() returned %d items, want 2", len(list)) - } - - // Newest first - if list[0].ID != newer.ID { - t.Errorf("list[0].ID = %q, want %q (newest first)", list[0].ID, newer.ID) - } - if list[1].ID != older.ID { - t.Errorf("list[1].ID = %q, want %q (oldest last)", list[1].ID, older.ID) - } -} - -func TestListShares_Empty(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - - list, err := store.ListShares(ctx) - if err != nil { - t.Fatalf("ListShares() empty table error = %v", err) - } - if list == nil { - t.Error("ListShares() returned nil slice, want empty non-nil slice") - } - if len(list) != 0 { - t.Errorf("ListShares() returned %d items, want 0", len(list)) - } -} - -func TestDeleteShare_Removes(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - share := makeTestShare("share-delete-1") - - if err := store.UpsertShare(ctx, share); err != nil { - t.Fatalf("UpsertShare() error = %v", err) - } - - // Verify it exists - if _, err := store.GetShare(ctx, share.ID); err != nil { - t.Fatalf("GetShare() before delete error = %v", err) - } - - // Delete - if err := store.DeleteShare(ctx, share.ID); err != nil { - t.Fatalf("DeleteShare() error = %v", err) - } - - // Verify it's gone - _, err := store.GetShare(ctx, share.ID) - if !errors.Is(err, storage.ErrShareNotFound) { - t.Errorf("GetShare() after delete: got %v, want storage.ErrShareNotFound", err) - } -} - -func TestDeleteShare_Idempotent(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - - // Delete an ID that never existed — should not error - err := store.DeleteShare(ctx, "nonexistent-share-id") - if err != nil { - t.Errorf("DeleteShare() on missing ID error = %v, want nil", err) - } -} diff --git a/internal/storage/sqlite/store.go b/internal/storage/sqlite/store.go index a76dc2d..5bcaf32 100644 --- a/internal/storage/sqlite/store.go +++ b/internal/storage/sqlite/store.go @@ -10,7 +10,6 @@ import ( "path/filepath" "time" - pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" "github.com/sentiolabs/arc/internal/storage" "github.com/sentiolabs/arc/internal/storage/sqlite/db" @@ -85,7 +84,7 @@ func New(path string) (*Store, error) { // initSchema backs up the database, then runs all pending migrations. // If a migration fails and a backup exists, the database is restored // to its pre-migration state. -func (s *Store) initSchema(ctx context.Context) error { +func (s *Store) initSchema(_ context.Context) error { backupPath, err := backupForMigration(s.db, s.path) if err != nil { // Non-fatal: migrating without a backup is better than not migrating @@ -113,17 +112,11 @@ func (s *Store) initSchema(ctx context.Context) error { _ = os.Remove(backupPath) } - // Apply paste subsystem migrations on the same database. - if err := pastesqlite.Apply(ctx, s.db); err != nil { - return fmt.Errorf("apply paste migrations: %w", err) - } - return nil } // DB returns the underlying *sql.DB connection. -// It is used by callers that need direct database access (e.g. to register -// additional migration-based subsystems such as the paste package). +// It is used by callers that need direct database access. func (s *Store) DB() *sql.DB { return s.db } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index faee4cd..75f73f5 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -3,14 +3,10 @@ package storage import ( "context" - "errors" "github.com/sentiolabs/arc/internal/types" ) -// ErrShareNotFound is returned when a requested share does not exist. -var ErrShareNotFound = errors.New("share not found") - //nolint:interfacebloat // Storage interface intentionally covers all operations as a single contract type Storage interface { // Projects @@ -91,13 +87,6 @@ type Storage interface { ListAIAgents(ctx context.Context, sessionID string) ([]*types.AIAgent, error) GetAgentSummariesForSessions(ctx context.Context, sessionIDs []string) (map[string]*types.AgentSummary, error) - // Shares (author-side keyring) - UpsertShare(ctx context.Context, share *types.Share) error - UpsertShares(ctx context.Context, shares []*types.Share) error - GetShare(ctx context.Context, id string) (*types.Share, error) - ListShares(ctx context.Context) ([]*types.Share, error) - DeleteShare(ctx context.Context, id string) error - // Events (audit trail) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) diff --git a/internal/types/shares_test.go b/internal/types/shares_test.go deleted file mode 100644 index 270219b..0000000 --- a/internal/types/shares_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package types_test - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/sentiolabs/arc/internal/types" -) - -// --- Contract assertions --- -// Verify Share JSON tag stability — wire format must stay backward-compatible -// with the legacy shares.json field names so the one-shot import is 1:1. -func TestShareJSONTags(t *testing.T) { - b, _ := json.Marshal(types.Share{ - ID: "x", Kind: types.ShareKindLocal, URL: "u", - KeyB64Url: "k", EditToken: "t", PlanFile: "p", - }) - for _, want := range []string{ - `"id"`, `"kind"`, `"url"`, `"key_b64url"`, - `"edit_token"`, `"plan_file"`, `"created_at"`, - } { - if !strings.Contains(string(b), want) { - t.Errorf("missing JSON tag %s in %s", want, b) - } - } -} - -func TestShareKindIsValid(t *testing.T) { - cases := []struct { - kind types.ShareKind - want bool - }{ - {types.ShareKindLocal, true}, - {types.ShareKindShared, true}, - {"", false}, - {"bogus", false}, - } - for _, tc := range cases { - if got := tc.kind.IsValid(); got != tc.want { - t.Errorf("ShareKind(%q).IsValid() = %v, want %v", tc.kind, got, tc.want) - } - } -} - -func TestShareValidate(t *testing.T) { - valid := types.Share{ID: "id", Kind: types.ShareKindLocal, URL: "u", KeyB64Url: "k", EditToken: "t"} - if err := valid.Validate(); err != nil { - t.Fatalf("valid share: unexpected error: %v", err) - } - cases := map[string]types.Share{ - "missing id": {Kind: types.ShareKindLocal, URL: "u", KeyB64Url: "k", EditToken: "t"}, - "invalid kind": {ID: "id", Kind: "x", URL: "u", KeyB64Url: "k", EditToken: "t"}, - "missing url": {ID: "id", Kind: types.ShareKindLocal, KeyB64Url: "k", EditToken: "t"}, - "missing key_b64url": {ID: "id", Kind: types.ShareKindLocal, URL: "u", EditToken: "t"}, - "missing edit_token": {ID: "id", Kind: types.ShareKindLocal, URL: "u", KeyB64Url: "k"}, - } - for name, s := range cases { - if err := s.Validate(); err == nil { - t.Errorf("%s: expected error, got nil", name) - } - } -} - -func TestAllShareKinds(t *testing.T) { - kinds := types.AllShareKinds() - if len(kinds) != 2 { - t.Fatalf("expected 2 kinds, got %d", len(kinds)) - } - found := map[types.ShareKind]bool{} - for _, k := range kinds { - found[k] = true - } - for _, want := range []types.ShareKind{types.ShareKindLocal, types.ShareKindShared} { - if !found[want] { - t.Errorf("AllShareKinds missing %q", want) - } - } -} diff --git a/internal/types/types.go b/internal/types/types.go index d5a0413..be85c1e 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -345,55 +345,6 @@ type MergeResult struct { SourcesDeleted []string `json:"sources_deleted"` } -// ShareKind distinguishes local-only shares from hosted (published) shares. -type ShareKind string - -const ( - ShareKindLocal ShareKind = "local" - ShareKindShared ShareKind = "shared" -) - -// IsValid checks if the share kind value is valid. -func (k ShareKind) IsValid() bool { - return k == ShareKindLocal || k == ShareKindShared -} - -// AllShareKinds returns all valid share kind values. -func AllShareKinds() []ShareKind { - return []ShareKind{ShareKindLocal, ShareKindShared} -} - -// Share represents an entry in the author-side keyring of paste shares created on this machine. -type Share struct { - ID string `json:"id"` - Kind ShareKind `json:"kind"` - URL string `json:"url"` - KeyB64Url string `json:"key_b64url"` - EditToken string `json:"edit_token"` - PlanFile string `json:"plan_file,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// Validate checks if the share has valid field values. -func (s *Share) Validate() error { - if s.ID == "" { - return errors.New("share: id is required") - } - if !s.Kind.IsValid() { - return fmt.Errorf("share: invalid kind %q", s.Kind) - } - if s.URL == "" { - return errors.New("share: url is required") - } - if s.KeyB64Url == "" { - return errors.New("share: key_b64url is required") - } - if s.EditToken == "" { - return errors.New("share: edit_token is required") - } - return nil -} - // Workspace represents a directory path associated with a project. // Multiple workspaces can be linked to a single project to support multi-directory projects. // Previously named WorkspacePath; renamed because this IS the workspace (a directory where work happens). From 73b77394e9eaeb2a040eff3e70297c9ee2a39082 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:38:13 -0700 Subject: [PATCH 06/11] refactor(config): remove share config schema Remove ShareConfig struct, Config.Share field, share.server validation, legacy share_author/share_server migration mapping, and arc config command handling for share.author/share.server keys. --- cmd/arc/config.go | 20 ++------------------ cmd/arc/config_test.go | 10 +++------- internal/api/config_test.go | 18 ++++-------------- internal/config/config.go | 8 -------- internal/config/config_test.go | 4 ---- internal/config/migrate.go | 18 ++++-------------- internal/config/migrate_test.go | 7 +------ internal/config/save_test.go | 6 +++--- internal/config/validate.go | 3 --- internal/config/validate_test.go | 12 ------------ 10 files changed, 17 insertions(+), 89 deletions(-) diff --git a/cmd/arc/config.go b/cmd/arc/config.go index 4102f1c..92243c7 100644 --- a/cmd/arc/config.go +++ b/cmd/arc/config.go @@ -26,10 +26,8 @@ const dottedKeyParts = 2 // These are checked before the Levenshtein fallback in normalizeKey so that // well-known old names always produce the correct "did you mean" hint. var legacyAliases = map[string]string{ - "server_url": "cli.server", - "share_author": "share.author", - "share_server": "share.server", - "channel": "updates.channel", + "server_url": "cli.server", + "channel": "updates.channel", } // recognizedKeys is the canonical list of all valid config key names. @@ -37,8 +35,6 @@ var recognizedKeys = []string{ "cli.server", "server.port", "server.db_path", - "share.author", - "share.server", "updates.channel", } @@ -140,10 +136,6 @@ func runConfigList(cmd *cobra.Command, args []string) error { printRow("server.port", strconv.Itoa(cfg.Server.Port)) printRow("server.db_path", cfg.Server.DBPath) fmt.Println() - fmt.Println("[share]") - printRow("share.author", cfg.Share.Author) - printRow("share.server", cfg.Share.Server) - fmt.Println() fmt.Println("[updates]") printRow("updates.channel", cfg.Updates.Channel) fmt.Println() @@ -324,10 +316,6 @@ func getKey(cfg *cfgpkg.Config, key string) string { return strconv.Itoa(cfg.Server.Port) case "server.db_path": return cfg.Server.DBPath - case "share.author": - return cfg.Share.Author - case "share.server": - return cfg.Share.Server case "updates.channel": return cfg.Updates.Channel } @@ -348,10 +336,6 @@ func setKey(cfg *cfgpkg.Config, key, value string) error { cfg.Server.Port = n case "server.db_path": cfg.Server.DBPath = value - case "share.author": - cfg.Share.Author = value - case "share.server": - cfg.Share.Server = value case "updates.channel": cfg.Updates.Channel = value } diff --git a/cmd/arc/config_test.go b/cmd/arc/config_test.go index 1ce8c8f..cee05e2 100644 --- a/cmd/arc/config_test.go +++ b/cmd/arc/config_test.go @@ -20,7 +20,7 @@ func TestConfigSetGetRoundTrip(t *testing.T) { if err != nil { t.Fatalf("loadConfig: %v", err) } - if err := setKey(cfg, "share.author", "Ada"); err != nil { + if err := setKey(cfg, "cli.server", "http://example.com:9000"); err != nil { t.Fatalf("setKey: %v", err) } if err := saveConfig(cfg); err != nil { @@ -31,8 +31,8 @@ func TestConfigSetGetRoundTrip(t *testing.T) { if err != nil { t.Fatalf("reload: %v", err) } - if got.Share.Author != "Ada" { - t.Errorf("share.author = %q", got.Share.Author) + if got.CLI.Server != "http://example.com:9000" { + t.Errorf("cli.server = %q", got.CLI.Server) } } @@ -52,8 +52,6 @@ func TestNormalizeKeyLegacyAliases(t *testing.T) { want string }{ {"server_url", "cli.server"}, - {"share_author", "share.author"}, - {"share_server", "share.server"}, {"channel", "updates.channel"}, } for _, tc := range cases { @@ -95,8 +93,6 @@ func TestNormalizeKeyValid(t *testing.T) { "cli.server", "server.port", dbPathKey, - "share.author", - "share.server", "updates.channel", } for _, k := range validKeys { diff --git a/internal/api/config_test.go b/internal/api/config_test.go index e9a7c83..b40cd67 100644 --- a/internal/api/config_test.go +++ b/internal/api/config_test.go @@ -13,8 +13,6 @@ import ( cfgpkg "github.com/sentiolabs/arc/internal/config" ) -const testAuthor = "Grace" - func newTestServerWithTempHome(t *testing.T) *Server { t.Helper() home := t.TempDir() @@ -44,7 +42,7 @@ func TestGetConfigReturnsDefaults(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v", err) } - for _, key := range []string{"cli", "server", "share", "updates", "meta"} { + for _, key := range []string{"cli", "server", "updates", "meta"} { if got[key] == nil { t.Errorf("response missing key %q: %v", key, got) } @@ -69,7 +67,6 @@ func TestGetConfigReturnsDefaults(t *testing.T) { func TestPutConfigPersistsAndRevalidates(t *testing.T) { s := newTestServerWithTempHome(t) in := cfgpkg.Default() - in.Share.Author = testAuthor body, err := json.Marshal(in) if err != nil { t.Fatalf("marshal: %v", err) @@ -85,18 +82,11 @@ func TestPutConfigPersistsAndRevalidates(t *testing.T) { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body) } - // Assert response body contains share.author and meta. + // Assert response body contains updates and meta. var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal response: %v", err) } - share, ok := got["share"].(map[string]any) - if !ok { - t.Fatalf("share is not an object: %T", got["share"]) - } - if share["author"] != testAuthor { - t.Errorf("response share.author = %q, want %q", share["author"], testAuthor) - } if got["meta"] == nil { t.Errorf("response missing meta field") } @@ -106,8 +96,8 @@ func TestPutConfigPersistsAndRevalidates(t *testing.T) { if err != nil { t.Fatalf("reload: %v", err) } - if reloaded.Share.Author != testAuthor { - t.Errorf("disk share.author = %q", reloaded.Share.Author) + if reloaded.Updates.Channel != in.Updates.Channel { + t.Errorf("disk updates.channel = %q, want %q", reloaded.Updates.Channel, in.Updates.Channel) } } diff --git a/internal/config/config.go b/internal/config/config.go index c42d49c..9d93a57 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,7 +6,6 @@ package config type Config struct { CLI CLIConfig `toml:"cli" json:"cli"` Server ServerConfig `toml:"server" json:"server"` - Share ShareConfig `toml:"share" json:"share"` Updates UpdatesConfig `toml:"updates" json:"updates"` } @@ -29,12 +28,6 @@ func (s ServerConfig) ResolvedDBPath() string { return expandHome(s.DBPath) } -// ShareConfig holds defaults for `arc share` and the web share UI. -type ShareConfig struct { - Author string `toml:"author" json:"author"` - Server string `toml:"server" json:"server"` -} - // UpdatesConfig holds update-channel settings for `arc self`. type UpdatesConfig struct { Channel string `toml:"channel" json:"channel"` @@ -48,7 +41,6 @@ func Default() *Config { return &Config{ CLI: CLIConfig{Server: "http://localhost:7432"}, Server: ServerConfig{Port: DefaultServerPort, DBPath: "~/.arc/data.db"}, - Share: ShareConfig{Server: "https://arcplanner.sentiolabs.io"}, Updates: UpdatesConfig{Channel: "stable"}, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0cb5079..88b27cb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,7 +11,6 @@ import ( var ( _ config.CLIConfig = config.Config{}.CLI _ config.ServerConfig = config.Config{}.Server - _ config.ShareConfig = config.Config{}.Share _ config.UpdatesConfig = config.Config{}.Updates ) @@ -28,9 +27,6 @@ func TestDefaultIsUsable(t *testing.T) { if cfg.Updates.Channel != "stable" { t.Fatalf("Default channel = %q, want stable", cfg.Updates.Channel) } - if cfg.Share.Server == "" { - t.Fatal("Default share.server is empty") - } } func TestRequiresRestartContainsServerKeys(t *testing.T) { diff --git a/internal/config/migrate.go b/internal/config/migrate.go index 001b48c..90dbb9b 100644 --- a/internal/config/migrate.go +++ b/internal/config/migrate.go @@ -7,19 +7,15 @@ import ( ) type legacyJSON struct { - ServerURL string `json:"server_url"` - Channel string `json:"channel"` - ShareAuthor string `json:"share_author"` - ShareServer string `json:"share_server"` + ServerURL string `json:"server_url"` + Channel string `json:"channel"` } // migrateLegacyJSON reads the flat ~/.arc/cli-config.json shape and maps it // onto the new Config. Old keys map as: // -// server_url → cli.server -// channel → updates.channel -// share_author → share.author -// share_server → share.server +// server_url → cli.server +// channel → updates.channel // // Unknown keys are ignored. func migrateLegacyJSON(jsonPath string) (*Config, error) { @@ -38,11 +34,5 @@ func migrateLegacyJSON(jsonPath string) (*Config, error) { if legacy.Channel != "" { cfg.Updates.Channel = legacy.Channel } - if legacy.ShareAuthor != "" { - cfg.Share.Author = legacy.ShareAuthor - } - if legacy.ShareServer != "" { - cfg.Share.Server = legacy.ShareServer - } return cfg, nil } diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go index 17182c6..23a46d6 100644 --- a/internal/config/migrate_test.go +++ b/internal/config/migrate_test.go @@ -13,9 +13,7 @@ func TestLoadMigratesLegacyJSON(t *testing.T) { legacy := filepath.Join(dir, "cli-config.json") if err := os.WriteFile(legacy, []byte(`{ "server_url": "http://example:1234", - "channel": "rc", - "share_author": "Grace", - "share_server": "https://share.example" + "channel": "rc" }`), 0o600); err != nil { t.Fatalf("seed legacy: %v", err) } @@ -30,9 +28,6 @@ func TestLoadMigratesLegacyJSON(t *testing.T) { if cfg.Updates.Channel != "rc" { t.Errorf("updates.channel = %q", cfg.Updates.Channel) } - if cfg.Share.Author != "Grace" { - t.Errorf("share.author = %q", cfg.Share.Author) - } if _, err := os.Stat(legacy); !os.IsNotExist(err) { t.Errorf("legacy file still present: %v", err) } diff --git a/internal/config/save_test.go b/internal/config/save_test.go index 21bb15d..f67004c 100644 --- a/internal/config/save_test.go +++ b/internal/config/save_test.go @@ -12,7 +12,7 @@ func TestSaveLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") cfg := config.Default() - cfg.Share.Author = "Ada" + cfg.CLI.Server = "http://example.com:9000" if err := config.Save(path, cfg); err != nil { t.Fatalf("Save: %v", err) } @@ -27,8 +27,8 @@ func TestSaveLoadRoundTrip(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if got.Share.Author != "Ada" { - t.Errorf("share.author = %q, want Ada", got.Share.Author) + if got.CLI.Server != "http://example.com:9000" { + t.Errorf("cli.server = %q, want http://example.com:9000", got.CLI.Server) } } diff --git a/internal/config/validate.go b/internal/config/validate.go index 9a9e0fd..e89b69e 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -37,9 +37,6 @@ func Validate(cfg *Config) error { if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { errs["server.port"] = "must be between 1 and 65535" } - if u, err := url.Parse(cfg.Share.Server); err != nil || u.Scheme == "" || u.Host == "" { - errs["share.server"] = "must be a valid URL with scheme and host" - } // Check that updates.channel is one of the allowed values. channelOK := false for _, c := range ValidChannels { diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 757dafd..a1eed50 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -65,15 +65,3 @@ func TestValidateRejectsEmptyCLIServer(t *testing.T) { } } -func TestValidateRejectsEmptyShareServer(t *testing.T) { - cfg := config.Default() - cfg.Share.Server = "" - err := config.Validate(cfg) - var ve config.ValidationError - if !errors.As(err, &ve) { - t.Fatalf("err type = %T, want ValidationError", err) - } - if _, ok := ve["share.server"]; !ok { - t.Errorf("missing share.server in errors: %v", ve) - } -} From 97423a5e542d12b87a96b8a124edba16038c26f1 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:41:30 -0700 Subject: [PATCH 07/11] refactor(api): drop ShareConfig from OpenAPI config schema Remove the `share` property and `ShareConfig` schema from the OpenAPI config schemas, and regenerate `openapi.gen.go` and `types.ts` to match the Go `Config` struct which no longer has a `Share` field. --- api/openapi.yaml | 14 +-- internal/api/openapi.gen.go | 170 +++++++++++++++++------------------- web/src/lib/api/types.ts | 7 +- 3 files changed, 84 insertions(+), 107 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 1591380..fdbc87e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2138,14 +2138,12 @@ components: # ==================== Config: type: object - required: [cli, server, share, updates] + required: [cli, server, updates] properties: cli: $ref: "#/components/schemas/CLIConfig" server: $ref: "#/components/schemas/ServerConfig" - share: - $ref: "#/components/schemas/ShareConfig" updates: $ref: "#/components/schemas/UpdatesConfig" @@ -2166,14 +2164,6 @@ components: db_path: type: string - ShareConfig: - type: object - properties: - author: - type: string - server: - type: string - UpdatesConfig: type: object properties: @@ -2183,7 +2173,7 @@ components: ConfigResponse: type: object - required: [cli, server, share, updates, meta] + required: [cli, server, updates, meta] allOf: - $ref: "#/components/schemas/Config" - type: object diff --git a/internal/api/openapi.gen.go b/internal/api/openapi.gen.go index d777dbd..41a1aff 100644 --- a/internal/api/openapi.gen.go +++ b/internal/api/openapi.gen.go @@ -236,7 +236,6 @@ type Comment struct { type Config struct { Cli CLIConfig `json:"cli"` Server ServerConfig `json:"server"` - Share ShareConfig `json:"share"` Updates UpdatesConfig `json:"updates"` } @@ -248,7 +247,6 @@ type ConfigResponse struct { RequiresRestart []string `json:"requires_restart"` } `json:"meta,omitempty"` Server ServerConfig `json:"server"` - Share ShareConfig `json:"share"` Updates UpdatesConfig `json:"updates"` } @@ -537,12 +535,6 @@ type ServerConfig struct { Port *int `json:"port,omitempty"` } -// ShareConfig defines model for ShareConfig. -type ShareConfig struct { - Author *string `json:"author,omitempty"` - Server *string `json:"server,omitempty"` -} - // Statistics defines model for Statistics. type Statistics struct { AvgLeadTimeHours *float64 `json:"avg_lead_time_hours,omitempty"` @@ -5852,87 +5844,87 @@ var swaggerSpec = []string{ "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", - "DLnEM5j4ezIldJ++Pv1/AAAA///5geWhaq4AAA==", + "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", + "1cGtAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 2b659b0..bbcabd1 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -1012,7 +1012,6 @@ export interface components { Config: { cli: components["schemas"]["CLIConfig"]; server: components["schemas"]["ServerConfig"]; - share: components["schemas"]["ShareConfig"]; updates: components["schemas"]["UpdatesConfig"]; }; CLIConfig: { @@ -1023,15 +1022,11 @@ export interface components { port?: number; db_path?: string; }; - ShareConfig: { - author?: string; - server?: string; - }; UpdatesConfig: { /** @enum {string} */ channel?: "stable" | "rc" | "nightly"; }; - ConfigResponse: WithRequired & { + ConfigResponse: WithRequired & { meta: { path: string; requires_restart: string[]; From c7f8530f1f3568f53228b2fea54bb88f2df99af1 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:44:01 -0700 Subject: [PATCH 08/11] feat(storage): drop share/paste tables via migration 018 --- .../storage/sqlite/migrations/018_drop_shares.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 internal/storage/sqlite/migrations/018_drop_shares.sql diff --git a/internal/storage/sqlite/migrations/018_drop_shares.sql b/internal/storage/sqlite/migrations/018_drop_shares.sql new file mode 100644 index 0000000..7323d4a --- /dev/null +++ b/internal/storage/sqlite/migrations/018_drop_shares.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Remove the arc-paste / share stack. The shares table (017) and the paste +-- engine's own tables are dropped here. Forward-only: 017 is left intact as a +-- released migration; this migration supersedes it. +DROP TABLE IF EXISTS paste_events; +DROP TABLE IF EXISTS paste_shares; +DROP TABLE IF EXISTS paste_migrations; +DROP TABLE IF EXISTS shares; + +-- +goose Down +-- Rollback not supported for this cleanup migration From 254d5cf16c9c5dc268829838c682c724531371ed Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:45:36 -0700 Subject: [PATCH 09/11] docs: remove arc-paste/share runbooks --- docs/runbooks/paste-server.md | 256 ---------------------------------- docs/runbooks/review_howto.md | 193 ------------------------- 2 files changed, 449 deletions(-) delete mode 100644 docs/runbooks/paste-server.md delete mode 100644 docs/runbooks/review_howto.md diff --git a/docs/runbooks/paste-server.md b/docs/runbooks/paste-server.md deleted file mode 100644 index 6d41d8d..0000000 --- a/docs/runbooks/paste-server.md +++ /dev/null @@ -1,256 +0,0 @@ -# Manual test runbook — paste server (local + shared review) - -This runbook walks through end-to-end manual testing of the encrypted paste service that backs `arc share` and the `/share/[id]` SvelteKit UI. Use it after rebuilding the binaries or before cutting a release. - -For background on the architecture, see [`docs/plans/2026-04-29-shared-review.md`](../plans/2026-04-29-shared-review.md). - -## Prerequisites - -- `bun` and `go` installed -- A clean checkout on `feat/add-shared-review` (or whatever branch contains `internal/paste/`, `arc-paste/`, and `cmd/arc/share.go`) - -## 1. Build everything - -arc uses Go build tags to gate the embedded SPA. Files in `web/` have `//go:build webui` (real embed) vs `//go:build !webui` (stub no-op `RegisterSPA`). Without the `webui` tag, **both binaries will return JSON 404 for `/share/`** — they have the API but no SPA. - -```bash -# From the worktree root - -# arc-server WITH embedded SPA (this is the one you want for manual testing) -make build # depends on `web-build` + `build-bin --webui` - -# arc-paste with embedded SPA -make build-paste # builds web/ then `go build -tags webui ./arc-paste` - -ls -la bin/ # expect: arc, arc-paste (the unified `arc` binary serves the API; `arc server start` boots the daemon) -``` - -> **Do NOT use `make build-quick`** for manual UI testing — that target produces a CLI-only binary with the stub `RegisterSPA` (no-op), and `/share/` will 404 with `{"message":"Not Found"}`. `build-quick` is fine for CLI-only flows like `arc share comments` but won't render the UI. - -If you skip the SPA build step, `/share/[id]` will return 404 even with the right tag because the embedded filesystem will be empty. - -## 2. Local review - -Local mode hosts the paste API and SPA on the same `arc-server` binary that already serves arc's issues/projects. The encryption key is auto-generated, persisted to `~/.arc/shares.json`, and embedded into the URL fragment so the browser can decrypt. - -### Start the server - -```bash -# Terminal 1 -./bin/arc server start --foreground -# listens on :7432; paste handlers mounted at /api/paste/* -# (drop --foreground to run as a daemon; use `arc server logs` to tail it) -``` - -### Create a test plan and share it - -```bash -# Terminal 2 -cat > /tmp/test-plan.md <<'EOF' -# Test Plan - -## Goal -Validate the shared review feature. - -## Approach -- Selection-based annotation -- Conventional labels -- Resolve / accept / reject -EOF - -./bin/arc share create /tmp/test-plan.md -# Output: -# Preview URL (local-only — not reachable by others): -# http://localhost:7432/share/#k=&t= -# -# Edit token saved to ~/.arc/shares.json - -./bin/arc share list # see all known shares -ls -la ~/.arc/shares.json # verify file mode 0600 -./bin/arc share show --author-url # reprint the author URL on demand -``` - -The author identity embedded in the plan is resolved in this order (highest to lowest priority): - -1. `--author "Name"` flag on `arc share create` -2. `share_author` field in `~/.arc/cli-config.json` (set once, applies to every share you create) -3. `$ARC_SHARE_AUTHOR` env var -4. `git config user.name` - -**Note the name that gets used** — it is embedded in the share bundle and displayed automatically when the Author URL is opened. If none of these produce a value, `arc share create` prints a warning and Accept/Resolve/Reject controls won't appear for anyone. - -To set a persistent default once: - -```bash -# Edit ~/.arc/cli-config.json and add: -# "share_author": "Ben Firestone" -# or, if you prefer: -echo '{"share_author":"Ben Firestone"}' | jq -s '.[0] * .[1]' ~/.arc/cli-config.json - \ - > ~/.arc/cli-config.json.tmp && mv ~/.arc/cli-config.json.tmp ~/.arc/cli-config.json -``` - -### Exercise the UI - -Open the **Author URL** (the one with `&t=`) in Chrome/Firefox. - -1. **Open the Author URL.** The header chip immediately reads ` · author` (no modal). Author detection is now driven by the `&t=` fragment param, not a name-string match — so even if your localStorage holds a different name, the page knows you're the author and renders Accept/Resolve/Reject controls. (Remember the URL printed by `arc share create`; if you've lost it, run `arc share show --author-url`.) -2. **Highlight a paragraph** in the rendered plan → floating annotation toolbar appears. -3. **Pick a label** (`praise` / `issue` / `suggestion` / `question` / `nit`). -4. **Type a comment**, optionally toggle "Suggest replacement text". -5. **Post** — the first time you do this, a small modal asks for your name. (As the author you should already have a name from step 1; the modal won't fire.) The comment appears in the sidebar. -6. As the author, you'll see **Accept / Resolve / Reject** controls on every comment. Try Accept on one, Reject (with reply) on another, and Resolve on a third. If the controls don't appear, you opened a reviewer URL (no `&t=`) instead of the Author URL — close the tab and reopen via the Author URL. -7. As a reviewer (a fresh browser profile or incognito window with NO `&t=` in the URL), find your own annotation in the sidebar. You should see an **✎ Edit** button on it. Click it; the body becomes a textarea pre-filled with your existing comment. Refine the wording — e.g. expand "expand this more" into a fully-formed suggestion — and **⌘/Ctrl-⏎** (or click Save). The card re-renders with `· edited Nm` next to the timestamp. Confirm `arc share comments ` prints the new body, not the original. -8. Switch back to the author window. The **✎ Edit** button should also appear on every reviewer's annotation (not just your own). Use it to sharpen a thin reviewer comment — the displayed `author_name` stays as the reviewer, only the body changes. Run `arc share comments ` again and confirm the refined body shows up. -9. **Click the chip** in the header — the name prompt opens prefilled with your current name (text selected). Edit and save → chip updates. This is the rename affordance. - -### Pull comments back to the CLI - -```bash -./bin/arc share comments # all comments + statuses -./bin/arc share pull # accepted-only (the brainstorm-flow form) -./bin/arc share comments --json # machine-readable -``` - -### Simulate a second reviewer - -Without spinning up another machine, click the in-page **Share link** button (in the page header on the author's tab) to copy the reviewer URL (`#k=…` only, no `&t=`), then open that URL in an **incognito window** (or a different browser entirely). Incognito gets a fresh `localStorage`, so: - -1. **No chip** appears in the header. There's no "Sign in" button anywhere — identity is captured lazily. -2. Highlight a paragraph and click Comment in the toolbar. A name prompt fires; type any name (e.g. "Reviewer-2"). The comment posts; the chip now reads `Reviewer-2` (no `· author`, because the URL has no `&t=`). -3. Post a few more comments (the prompt won't fire again — name is sticky in localStorage). -4. Refresh the author's window → new comments replay in via `replayEvents()`. - -The author's resolution events still apply: their UI is gated on `authorToken` (in-memory, parsed from `&t=` in the fragment), not a name match. Reviewer-2 cannot resolve comments because Reviewer-2 doesn't have the token — the Accept/Resolve/Reject buttons are never rendered for that profile. - -### Frictionless auth scenarios - -Walk these in order to confirm the new auth UX end-to-end: - -1. **Create a share.** `arc share create ` — confirm output prints a single `Preview URL` line (containing `&t=`), no raw `Edit token: ` line, and a "saved to ~/.arc/shares.json" pointer. -2. **Reprint the author URL.** `arc share show --author-url` — single line, contains `&t=`. -3. **Author URL flow.** Open the Preview URL in a fresh browser profile. Header chip shows ` · author` immediately, no modal. Accept / Resolve buttons visible on existing comments. -4. **Reviewer URL flow.** From the author's browser tab, click the **Share link** button in the page header — clipboard now holds the bare reviewer URL (`#k=…` only, no `&t=`). Open that copied URL in a different fresh profile. No chip in the header, no "Sign in" button. Select text → click Comment in the toolbar → name modal opens → save a name → comment posts → chip now shows the name. -5. **Rename via chip.** Click the chip on the reviewer side. Modal opens prefilled with the saved name (text selected). Edit, save → chip updates. -6. **Author opens the bare reviewer URL.** Click the in-page **Share link** button to copy the reviewer URL (`#k=…` only), then paste it into a new tab in the same browser. They are now in reviewer mode (no Accept buttons). Reopen via the Author URL → author mode restored. - -## 3. Shared / remote review - -Remote mode runs the standalone `arc-paste` binary (a thin wrapper around the same `internal/paste/` package). It owns its own SQLite, has CORS enabled, and can be deployed anywhere reachable. - -### Start arc-paste on a separate port - -```bash -# Terminal 1 -ARC_PASTE_ADDR=:7433 ARC_PASTE_DB=/tmp/arc-paste.db ./bin/arc-paste -``` - -Or via Docker for the production-style HTTPS stack (uses the `arc-paste/Dockerfile` scratch image, a named SQLite volume, and Caddy for `https://arcpaste.company.com`): - -```bash -docker compose -f arc-paste/compose.yaml up -d --build -docker compose -f arc-paste/compose.yaml logs -f -``` - -The compose file publishes Caddy on host ports 80/443 (including UDP 443 for HTTP/3), exposes `arc-paste` only on the internal Docker network, persists SQLite to the `arc-paste-data` named volume, persists Caddy certificate state to named volumes, and sets `restart: unless-stopped`. Caddy is configured via `arc-paste/Caddyfile` (mounted read-only) — edit the site address there to change the public hostname, or `email` in the global block to change the ACME contact. After editing, reload without downtime: - -```bash -docker compose -f arc-paste/compose.yaml exec caddy caddy reload --config /etc/caddy/Caddyfile -``` - -Make sure DNS for `arcpaste.company.com` points to the host and ports 80/443 are reachable for Let's Encrypt issuance and renewal. Note: the runtime image is `scratch`, so there's no in-container healthcheck — pair with an external probe (Cloudflare health check, Uptime Kuma, etc.) against the HTTPS endpoint for production. - -### Create a shared paste pointed at the remote server - -```bash -# Terminal 2 (Docker/Caddy stack) -./bin/arc share create /tmp/test-plan.md --server https://arcpaste.company.com -# → Author URL: https://arcpaste.company.com/share/#k=&t= - -# If you launched the standalone binary directly instead, use: -# ./bin/arc share create /tmp/test-plan.md --server http://localhost:7433 -``` - -The CLI prints only the Author URL — keep it private. To send a reviewer link (Slack, email, etc.), open the Author URL in your browser and click the **Share link** button in the page header; that copies a `#k=…`-only URL with no `&t=` token. - -### Pull the comments back - -```bash -./bin/arc share pull --accepted-only -``` - -The CLI looks up the share id in `~/.arc/shares.json` to find the server URL and decryption key — no need to paste the full URL again. - -### Simulate a public deploy - -To exercise the actual "remote" code paths (cross-origin, no shared filesystem), run `arc-paste` on a different host or behind a tunnel: - -```bash -# On a VPS, or via cloudflared/ngrok: -./bin/arc-paste - -# From your laptop: -./bin/arc share create plan.md --server https://share.example.com -``` - -For a persistent default, set `share_server` in `~/.arc/cli-config.json`: - -```json -{ - "server_url": "http://localhost:7432", - "share_author": "Ben Firestone", - "share_server": "https://share.example.com" -} -``` - -Then `arc share create plan.md --remote` will pick it up without any flag or env var. The full precedence is `--server flag → share_server in cli-config.json → $ARC_SHARE_SERVER → https://arcplanner.sentiolabs.io`. - -## 4. End-to-end via the brainstorm skill - -In a new Claude Code session, the agent-nexus brainstorm skill update can be exercised directly: - -``` -/arc:brainstorm let's design a small feature -``` - -When the skill reaches step 6, it should now offer three options via `AskUserQuestion`: - -- **Local review** → invokes `arc share create` (local is the default) -- **Shared review** → invokes `arc share create --remote` -- **Save for later** → no server registration - -Step 7 (review loop) uses `arc share approve` and `arc share pull` instead of the legacy `arc plan *` commands. - -## 5. Gotchas - -| Symptom | Cause | Fix | -|---|---|---| -| Accept / Resolve / Reject controls never appear | You opened a reviewer URL (no `&t=`) instead of the Author URL — author detection is token-based, not name-based. Or the share was created without an author name (`git config user.name` was empty and no `--author` flag passed), so `plan.author_name` is empty and the chip never shows `· author` | Close the tab and reopen via the Author URL (run `arc share show --author-url` to retrieve it). If the share has no author name, recreate it with `--author "Your Name"` | -| `/share/` returns `{"message":"Not Found"}` (Echo's default 404 JSON) | Binary built without the `webui` build tag — `web.RegisterSPA` is the no-op stub | Rebuild with `make build` (not `make build-quick`); for arc-paste use `go build -tags webui -o ./bin/arc-paste ./arc-paste` | -| `/share/` returns blank HTML / cannot find static assets | `web/build/` not present at compile time, even with the `webui` tag | Re-run `bun run build` in `web/`, then rebuild the binary | -| SPA console says `missing #k= in URL` | URL was pasted without its fragment | Use the full URL printed by `arc share create` — fragments are dropped by some chat apps; copy carefully | -| `arc share comments ` errors with "unknown share id" | Looking up an id you didn't create on this machine | Use the full URL: `arc share comments 'http://host/share/#k='` | -| Comments from another reviewer don't appear after refresh | Browser is caching `GET /api/paste/:id` | Hard reload (Cmd-Shift-R / Ctrl-Shift-R); the `arc-paste` server doesn't currently set Cache-Control headers | -| Lost `~/.arc/shares.json` | The only copy of edit_tokens + keys lives there | There is no recovery — same trade-off plannotator makes. Back up `~/.arc/` before destructive testing | -| CORS error in browser console (shared mode) | Talking to an arc-paste instance without `middleware.CORS()` | Verify you're running the binary built from this branch (`./bin/arc-paste --help` should exist; if not, rebuild via `make build-paste`) | - -## 6. Quick reset - -To wipe local state and start fresh: - -```bash -# Stop the servers first (Ctrl-C) -rm ~/.arc/shares.json # clears CLI registry -rm /tmp/arc-paste.db # arc-paste's blob DB (if used in step 3) - -# arc-server's paste tables live in arc.db alongside issues — to clear just paste state: -sqlite3 ~/.arc/arc.db 'DELETE FROM paste_events; DELETE FROM paste_shares;' -``` - -## See also - -- [`docs/plans/2026-04-29-shared-review.md`](../plans/2026-04-29-shared-review.md) — design doc with full architecture, data model, and phasing -- `internal/paste/` — Go package shared by `arc-server` and `arc-paste` -- `arc-paste/` — standalone binary -- `web/src/routes/share/[id]/` — SvelteKit UI -- `cmd/arc/share.go` — CLI subcommands -- `internal/sharesconfig/` — `~/.arc/shares.json` registry diff --git a/docs/runbooks/review_howto.md b/docs/runbooks/review_howto.md deleted file mode 100644 index 5774746..0000000 --- a/docs/runbooks/review_howto.md +++ /dev/null @@ -1,193 +0,0 @@ -# Reviewing a shared plan — how to use Accept / Resolve / Reject - -When someone shares a plan with you via `arc share create`, reviewers leave annotations and you (the plan author) close them out using one of three actions: **Accept**, **Resolve**, or **Reject**. This page explains what each one means, when to use each, and how they affect the downstream LLM consumer. - -## TL;DR - -| Action | Meaning | Flows to `arc share pull` (agent's queue)? | -|---|---|---| -| **Accept** | "I'll apply this to the plan." | ✅ Yes — `--accepted-only` is the default | -| **Resolve** | "Acknowledged, but no plan change needed." | ❌ No — closes the thread without queueing | -| **Reject** | "I disagree. Here's why (optional reply)." | ❌ No — reply preserved for the audit trail | -| **Reopen** | "On second thought, this should be active again." | Resets to `open` | - -Mental shortcut: - -- **Accept** = "do this" -- **Resolve** = "no-op, conversation done" -- **Reject** = "no, and here's why" - -The discriminator is whether the comment should *cause an edit downstream*. Accept is the only path that does. Resolve and Reject both close the thread; the difference is whether the disagreement is worth recording — Reject preserves a reply, Resolve doesn't. - -## URLs you'll receive - -`arc share create` prints exactly one URL — the **Author URL** (or **Preview URL** for `--local` shares). It contains both the decryption key and the `&t=` that grants Accept / Resolve / Reject. Treat it like a write password: don't paste it into tickets, screenshots, or shared chat threads. The first time you open it, the page detects your role from the URL itself; you don't sign in or pick a name. - -| URL form | What it is | Who gets it | Source | -|---|---|---|---| -| **Author URL** (`#k=…&t=…`) | Read + comment + Accept / Resolve / Reject | Plan author only | Printed by `arc share create` | -| **Reviewer URL** (`#k=…` only, no `&t=`) | Read + comment | Reviewers | Click the in-page **Share link** button on the share page header | - -To send a reviewer link: open the Author URL in your browser, click the **Share link** button in the page header, and paste the resulting URL into your message. The button strips `&t=` so the URL you share grants reviewer-only access — copy-pasting the bare Author URL would hand the recipient your edit token. - -Lost the URL? Run `arc share show --author-url` to reprint it (uses the `edit_token` saved to `~/.arc/shares.json`). - -Reviewers see no sign-in screen. The first time a reviewer leaves a comment, a small modal asks for their name. The name is stored in this browser only. - -## Concrete examples - -### Accept - -The comment proposes a real change you want made. - -> Steve: "The Goal section should mention success criteria for 'validated.'" - -→ Ben Accepts. → `arc share pull ` surfaces this. → Claude rewrites the Goal section. - -This is the path that produces actual plan edits. Treat Accept as a commitment to the change — once accepted, the comment locks (it stops being editable, since the meaning has been "consumed"). - -### Resolve - -The comment is valid but no plan edit is needed. - -Cases: - -- **Clarifying question with a satisfying answer.** Steve: "Isn't 'validated' already defined in the previous brainstorm?" → It is. The comment helped clarify; no plan change needed. → Resolve. -- **Already covered elsewhere.** Steve: "What about edge case X?" → You think about it, realize the existing design handles X via Y. The discussion is done; the plan doesn't need to change. → Resolve. -- **Off-topic but harmless.** A comment that's interesting but not actionable in this plan. - -Resolve closes the thread without sending instructions downstream. - -### Reject - -The suggestion is wrong, out-of-scope, or contradicts a constraint, and you want the reasoning recorded. - -> Steve: "Add a section about caching." -> Ben: caching is intentionally out of scope; we're tracking it in arc-1234. - -→ Reject with reply *"Caching is intentionally out of scope; tracked in arc-1234."* - -The reply is encrypted in the event log alongside the rejection. Two things happen: - -1. If Steve refreshes the share, he sees the rationale. -2. The agent never tries to apply the change, but the reasoning is preserved if anyone (including Claude) re-reads the share later. - -Use Reject — not Resolve — whenever you'd want a future reader to know *why* you didn't act. It's the audit-trail action. - -## Why three states instead of two - -You could collapse Resolve and Reject into a single "Decline" — GitHub roughly does (it's just "Resolve conversation"). This UI keeps them separate because: - -- The consumer is often an **LLM agent** that may re-read the share later. A `resolved` comment is "we discussed this and moved on"; a `rejected` comment with a reply is "the author considered this and explicitly disagreed because X." -- If you later ask Claude "why didn't we do the caching thing?", the rejected comment + reply gives it the exact answer. A resolved one leaves the question dangling. - -If in practice you find yourself never using one of these states, that's a signal we should simplify the UI. The current design errs on the side of preserving rationale, since "feedback that's helpful for an LLM" is the project's product goal. - -## Editing annotations - -Two roles can edit an annotation while it's `open` or `reopened`: - -1. **The original commenter** can refine their own wording. -2. **The plan author** can sharpen any reviewer's comment — useful for turning a thin "expand this more" into a fully-formed instruction the LLM can act on, without waiting on the reviewer. - -Either way, **`comment.author_name` doesn't change** — Steve's comment is still attributed to Steve even after Ben rewrites the body. Only the *body* (and `suggested_text`, `comment_type`) changes. The underlying edit event records who actually edited, so the audit trail is preserved if you ever want to inspect it. - -Once a comment is Accepted, Rejected, or Resolved, the edit button disappears for everyone. The reasoning: the meaning has been "consumed" by the resolution decision, and changing it after the fact would invalidate that decision. - -## JSON output for LLM consumers - -`arc share comments --json` emits a single JSON object structured for direct LLM consumption. - -**Local case** — share is registered in `~/.arc/shares.json` and the file is readable: - -```json -{ - "plan": { - "id": "abc123", - "title": "Test Plan", - "author_name": "Ben", - "file": "/abs/path/to/docs/plans/foo.md" - }, - "comments": [ - { - "comment": { - "kind": "comment", - "id": "c-abc", - "author_name": "Steve", - "comment_type": "issue", - "action": "comment", - "body": "Goal section should mention success criteria", - "anchor": { "line_start": 5, "line_end": 5, "quoted_text": "...", "heading_slug": "goal" }, - "created_at": "..." - }, - "status": "accepted", - "resolved_anchor": { - "status": "ok", - "line_start": 5, - "line_end": 5, - "snippet": "## Goal\n\nValidate the shared review feature." - } - } - ] -} -``` - -The agent reads `plan.file` directly — the markdown content isn't included to avoid bloating every CLI call with content the agent can read in one tool call. - -**Remote case** — share isn't registered locally (e.g. an agent consuming a shared URL it didn't create). The `file` field is omitted; `markdown_b64` carries the plan content base64-encoded: - -```json -{ - "plan": { - "id": "abc123", - "markdown_b64": "IyBUZXN0IFBsYW4KCiMjIEdvYWwK..." - }, - "comments": [...] -} -``` - -Base64 sidesteps the JSON-escape penalty for markdown (every `\n` and `\"` doubles the size and destroys readability when piped to `cat`). Decode with any standard base64 implementation. - -Key fields for an agent applying feedback: - -- **`plan.file`** *(local case)* — absolute path the agent should `Edit` directly. -- **`plan.markdown_b64`** *(remote case)* — base64-encoded plan content. Decode and write to disk if the agent needs to operate on a file. -- **`comment.action`** — `"comment"` (default) or `"delete"`. Delete annotations request removal of `quoted_text`; the body may be empty since the strikethrough IS the action. -- **`comment.suggested_text`** — when present, this is a literal find-and-replace candidate. -- **`resolved_anchor.status`** — `"ok"` if line numbers match the current content; `"drifted"` if the comment was relocated via the heading or fuzzy fallback (use the new line numbers); `"orphaned"` if the quoted text isn't in the current content (the agent should grep or skip). -- **`resolved_anchor.snippet`** — a few lines of context around the anchor, for orientation. - -`arc share pull --json` is the same shape filtered to `status === "accepted"` — typical agent input. - -## What flows where - -``` -Reviewer posts annotation - │ - ▼ -Comment status = open - │ - ├── Author: Accept ──► status=accepted ──► arc share pull picks it up ──► agent applies edit - ├── Author: Resolve ──► status=resolved (closed, no downstream effect) - ├── Author: Reject ──► status=rejected (closed, with reply for audit) - └── Author: Reopen ──► status=open (back in queue) -``` - -The CLI commands: - -```bash -# Show all comments + statuses (for a human reading the discussion): -arc share comments - -# Show only accepted comments — the agent's actionable queue: -arc share pull # alias for --accepted-only -arc share comments --accepted-only - -# Machine-readable form, used by the brainstorm skill: -arc share comments --json -``` - -## Related - -- [`docs/runbooks/paste-server.md`](runbooks/paste-server.md) — manual test runbook (covers the full flow including reviewer self-edits) -- [`docs/plans/2026-04-29-shared-review.md`](plans/2026-04-29-shared-review.md) — full design doc with event schema, replay logic, and CRDT semantics From e3378b9c4a68fcbee62992604056bbd903278868 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:50:27 -0700 Subject: [PATCH 10/11] refactor(web): remove vitest test infra (arc-paste artifact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete vitest.config.ts, remove vitest devDependency, and restore the test script to run playwright e2e — reverting the arc-paste foundation additions now that all paste/share test files are gone. --- web/bun.lock | 49 -------------------------------------------- web/package.json | 5 ++--- web/vitest.config.ts | 15 -------------- 3 files changed, 2 insertions(+), 67 deletions(-) delete mode 100644 web/vitest.config.ts diff --git a/web/bun.lock b/web/bun.lock index 5fc526f..b3e89e4 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -36,7 +36,6 @@ "tailwindcss": "^4.0.0", "typescript": "^5.9.3", "vite": "^7.2.6", - "vitest": "^4.1.5", }, }, }, @@ -299,12 +298,8 @@ "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -343,20 +338,6 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], - - "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], - - "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], - - "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], - - "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], - - "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -373,8 +354,6 @@ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -389,8 +368,6 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], @@ -413,8 +390,6 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -449,8 +424,6 @@ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -477,12 +450,8 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -661,8 +630,6 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -721,18 +688,12 @@ "shiki": ["shiki@4.0.0", "", { "dependencies": { "@shikijs/core": "4.0.0", "@shikijs/engine-javascript": "4.0.0", "@shikijs/engine-oniguruma": "4.0.0", "@shikijs/langs": "4.0.0", "@shikijs/themes": "4.0.0", "@shikijs/types": "4.0.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-rjKoiw30ZaFsM0xnPPwxco/Jftz/XXqZkcQZBTX4LGheDw8gCDEH87jdgaKDEG3FZO2bFOK27+sR/sDHhbBXfg=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -751,14 +712,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - - "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], @@ -805,8 +760,6 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], - "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], - "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], @@ -817,8 +770,6 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], diff --git a/web/package.json b/web/package.json index b9ea30c..db5b3d2 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ "lint:fix": "biome lint --write . && eslint . --fix", "format": "biome format --write . && prettier --write '**/*.svelte'", "format:check": "biome format . && prettier --check '**/*.svelte'", - "test": "vitest run", + "test": "playwright test --config playwright.e2e.config.ts", "test:e2e": "playwright test --config playwright.e2e.config.ts", "test:ui": "playwright test --ui", "generate": "openapi-typescript ../api/openapi.yaml -o src/lib/api/types.ts" @@ -44,8 +44,7 @@ "svelte-check": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "^5.9.3", - "vite": "^7.2.6", - "vitest": "^4.1.5" + "vite": "^7.2.6" }, "dependencies": { "isomorphic-dompurify": "^3.0.0", diff --git a/web/vitest.config.ts b/web/vitest.config.ts deleted file mode 100644 index d1c3ef3..0000000 --- a/web/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - // Vitest scope is intentionally narrow: only the paste/share-review unit - // tests we added. The rest of the suite uses Bun's native runner - // (`import from 'bun:test'`) or Playwright (e2e) and isn't compatible - // with Vitest's runtime. - include: [ - 'src/lib/paste/**/*.{test,spec}.{js,ts}', - 'src/routes/share/**/*.{test,spec}.{js,ts}' - ], - environment: 'node' - } -}); From 057ba429bd2a1200a02b8f94ec95b6cd0777686d Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sun, 7 Jun 2026 22:54:55 -0700 Subject: [PATCH 11/11] style(config): gofmt validate_test.go after share-validation removal --- internal/config/validate_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index a1eed50..973dde9 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -64,4 +64,3 @@ func TestValidateRejectsEmptyCLIServer(t *testing.T) { t.Errorf("missing cli.server in errors: %v", ve) } } -