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/api/openapi.yaml b/api/openapi.yaml index 239481a..fdbc87e 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 # ==================== @@ -2259,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" @@ -2287,14 +2164,6 @@ components: db_path: type: string - ShareConfig: - type: object - properties: - author: - type: string - server: - type: string - UpdatesConfig: type: object properties: @@ -2304,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/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 f379ef2..0000000 --- a/arc-paste/main.go +++ /dev/null @@ -1,134 +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 - -// robotsPath is the well-known URL path for the robots.txt file. -const robotsPath = "/robots.txt" - -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", robotsPath: - 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/cmd/arc/config.go b/cmd/arc/config.go index 0a06f28..f771063 100644 --- a/cmd/arc/config.go +++ b/cmd/arc/config.go @@ -26,8 +26,6 @@ const dottedKeyParts = 2 // config commands. const ( cliServerKey = "cli.server" - shareAuthorKey = "share.author" - shareServerKey = "share.server" updatesChannelKey = "updates.channel" plansDirKey = "plans.dir" serverPortKey = "server.port" @@ -44,10 +42,8 @@ const cmdEdit = "edit" // 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": cliServerKey, - "share_author": shareAuthorKey, - "share_server": shareServerKey, - "channel": updatesChannelKey, + "server_url": cliServerKey, + "channel": updatesChannelKey, } // recognizedKeys is the canonical list of all valid config key names. @@ -56,8 +52,6 @@ var recognizedKeys = []string{ plansDirKey, serverPortKey, serverDBPathKey, - shareAuthorKey, - shareServerKey, updatesChannelKey, } @@ -164,10 +158,6 @@ func runConfigList(cmd *cobra.Command, args []string) error { printRow(serverPortKey, strconv.Itoa(cfg.Server.Port)) printRow(serverDBPathKey, cfg.Server.DBPath) fmt.Println() - fmt.Println("[share]") - printRow(shareAuthorKey, cfg.Share.Author) - printRow(shareServerKey, cfg.Share.Server) - fmt.Println() fmt.Println("[updates]") printRow(updatesChannelKey, cfg.Updates.Channel) fmt.Println() @@ -391,10 +381,6 @@ func getKey(cfg *cfgpkg.Config, key string) string { return strconv.Itoa(cfg.Server.Port) case serverDBPathKey: return cfg.Server.DBPath - case shareAuthorKey: - return cfg.Share.Author - case shareServerKey: - return cfg.Share.Server case updatesChannelKey: return cfg.Updates.Channel } @@ -417,10 +403,6 @@ func setKey(cfg *cfgpkg.Config, key, value string) error { cfg.Server.Port = n case serverDBPathKey: cfg.Server.DBPath = value - case shareAuthorKey: - cfg.Share.Author = value - case shareServerKey: - cfg.Share.Server = value case updatesChannelKey: cfg.Updates.Channel = value } diff --git a/cmd/arc/config_test.go b/cmd/arc/config_test.go index aaba8e1..314c7e4 100644 --- a/cmd/arc/config_test.go +++ b/cmd/arc/config_test.go @@ -18,7 +18,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 { @@ -29,8 +29,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) } } @@ -50,8 +50,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 { @@ -93,8 +91,6 @@ func TestNormalizeKeyValid(t *testing.T) { "cli.server", "server.port", serverDBPathKey, - "share.author", - "share.server", "updates.channel", } for _, k := range validKeys { diff --git a/cmd/arc/main.go b/cmd/arc/main.go index 7533af8..5f3600a 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" @@ -273,13 +272,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 e4896ca..0000000 --- a/cmd/arc/share.go +++ /dev/null @@ -1,1024 +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" -) - -// Share review event kinds and action values, mirroring the schema in -// web/src/lib/paste/types.ts. -const ( - // shareKindComment is the event kind for a reviewer comment. - shareKindComment = "comment" - // shareKindEdit is the event kind for an author edit of a comment. - shareKindEdit = "edit" - // shareKindRetraction is the event kind for retracting a comment. - shareKindRetraction = "retraction" - // shareActionDelete is the comment action requesting a strikethrough deletion. - shareActionDelete = "delete" - // shareStatusOpen is the unresolved status for a review comment. - shareStatusOpen = "open" -) - -// --- 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: cmdList, - 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 shareKindComment: - applyCommentEvent(d.raw, comments) - case "resolution": - applyResolutionEvent(d.raw, planAuthor, resolutions) - case shareKindEdit: - applyEditEvent(d.raw, planAuthor, comments) - case shareKindRetraction: - 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 := shareStatusOpen - 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 == shareActionDelete { - 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/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. 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 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/api/openapi.gen.go b/internal/api/openapi.gen.go index 0f4a2b1..41a1aff 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" @@ -242,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"` } @@ -254,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"` } @@ -543,26 +535,6 @@ 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 +640,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 +856,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 +1008,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 +2319,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 +2397,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 +4261,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 +4413,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 +5822,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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", + "1cGtAAA=", } // 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(" 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 2a1a54c..e44a641 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -66,19 +66,6 @@ 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) - } -} - func TestValidatePlansDir(t *testing.T) { base := config.Default() base.Plans.Dir = "" 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/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) -} 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/server/server.go b/internal/server/server.go index 33bf5c5..c88f43d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,7 +12,6 @@ import ( "time" "github.com/sentiolabs/arc/internal/api" - "github.com/sentiolabs/arc/internal/sharesconfig" "github.com/sentiolabs/arc/internal/storage/sqlite" ) @@ -68,22 +67,8 @@ func Run(cfg Config) error { server := api.New(api.ServerOptions{ Address: cfg.Address, Store: store, - DB: store.DB(), }) - // One-shot import of legacy ~/.arc/shares.json keyring into the shares - // table. Idempotent — skips when the table already has rows. On success - // the JSON file is renamed to .bak so subsequent startups are no-ops. - if path, pathErr := sharesconfig.LegacyPath(); pathErr == nil { - n, importErr := server.ImportLegacySharesJSON(context.Background(), path) - switch { - case importErr != nil: - log.Printf("legacy shares.json import failed: %v", importErr) - case n > 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/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/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 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). 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/src/lib/api/types.ts b/web/src/lib/api/types.ts index fc7b5ec..bbcabd1 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; @@ -1071,7 +1012,6 @@ export interface components { Config: { cli: components["schemas"]["CLIConfig"]; server: components["schemas"]["ServerConfig"]; - share: components["schemas"]["ShareConfig"]; updates: components["schemas"]["UpdatesConfig"]; }; CLIConfig: { @@ -1082,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[]; @@ -2223,100 +2159,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; 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 } } 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' - } -});