diff --git a/.gitignore b/.gitignore index eaf97cf..5d6ac44 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,8 @@ coverage.html # Worktrees .worktrees/ +# eval workspaces +plan-* + # Config (contains user-specific settings) # Note: ~/.arc/cli-config.json is user config diff --git a/Makefile b/Makefile index df40361..c678f33 100644 --- a/Makefile +++ b/Makefile @@ -104,6 +104,11 @@ build-bin: ## Build arc binary with embedded web UI (requires frontend built fir build-quick: ## Build CLI-only binary (no embedded web UI) $(BUILD_SCRIPT) +.PHONY: build-paste +build-paste: web-build ## Build arc-paste standalone binary (with embedded SPA) + @echo "==> Building arc-paste binary..." + $(GO) build -tags webui -o $(BIN_DIR)/arc-paste ./arc-paste + .PHONY: release release: ## Build release with goreleaser (requires git tag) goreleaser release --clean diff --git a/arc-paste/Dockerfile b/arc-paste/Dockerfile new file mode 100644 index 0000000..ec4aa64 --- /dev/null +++ b/arc-paste/Dockerfile @@ -0,0 +1,59 @@ +# 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 new file mode 100644 index 0000000..6231f69 --- /dev/null +++ b/arc-paste/README.md @@ -0,0 +1,43 @@ +# 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 new file mode 100644 index 0000000..1ed5118 --- /dev/null +++ b/arc-paste/compose.yaml @@ -0,0 +1,40 @@ +# 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 +# +# 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 + ports: + - "7433: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 host port instead. + +volumes: + arc-paste-data: + name: arc-paste-data diff --git a/arc-paste/main.go b/arc-paste/main.go new file mode 100644 index 0000000..4e7b902 --- /dev/null +++ b/arc-paste/main.go @@ -0,0 +1,90 @@ +// 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" + "os" + "path/filepath" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + _ "modernc.org/sqlite" // match arc's driver + + "github.com/sentiolabs/arc/internal/paste" + pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" + "github.com/sentiolabs/arc/web" +) + +// dbDirMode is the permission used when creating the parent directory of the +// SQLite database. World-readable on purpose — the file mode itself (which +// SQLite controls) is what protects the data. +const dbDirMode = 0o755 + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + addr := envOr("ARC_PASTE_ADDR", ":7433") + dbPath := envOr("ARC_PASTE_DB", "./arc-paste.db") + + // Ensure the parent directory exists. SQLite will create the db file but + // not the directory, which matters when running under scratch / distroless + // images that mount a fresh volume at a path the binary has never seen. + if dir := filepath.Dir(dbPath); dir != "." && dir != "/" { + if err := os.MkdirAll(dir, dbDirMode); err != nil { + return fmt.Errorf("create db dir %q: %w", dir, err) + } + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + if err := pastesqlite.Apply(context.Background(), db); err != nil { + return fmt.Errorf("apply migrations: %w", err) + } + + store := pastesqlite.New(db) + handlers := paste.NewHandlers(store) + + 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()) + + // Mount paste handlers at /api/paste + handlers.Register(e.Group("/api/paste")) + + // Serve embedded SPA with fallback to index.html for routing + web.RegisterSPA(e) + + return e.Start(addr) +} + +// 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 new file mode 100644 index 0000000..c1adb7c --- /dev/null +++ b/arc-paste/main_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/sentiolabs/arc/internal/paste" + pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" + _ "modernc.org/sqlite" +) + +func TestArcPasteCreate(t *testing.T) { + db, _ := sql.Open("sqlite", ":memory:") + defer db.Close() + _ = pastesqlite.Apply(context.Background(), db) + e := echo.New() + paste.NewHandlers(pastesqlite.New(db)).Register(e.Group("/api/paste")) + + 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) + } +} diff --git a/cmd/arc/main.go b/cmd/arc/main.go index 58fac14..be66ab3 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -79,6 +79,25 @@ func main() { type Config struct { ServerURL string `json:"server_url"` Channel string `json:"channel,omitempty"` + // ShareAuthor is the default author name embedded in `arc share create` + // plans. It's the canonical reviewer identity used by the share UI to + // gate Accept / Resolve / Reject controls — only visitors who type this + // exact name in the SPA's prompt are recognized as the plan owner. + // Resolution precedence in `arc share create`: + // 1. --author flag (highest) + // 2. this config field + // 3. $ARC_SHARE_AUTHOR + // 4. `git config user.name` + ShareAuthor string `json:"share_author,omitempty"` + // ShareServer is the default URL for the remote paste server used by + // `arc share create --share`. Lets users persistently target a private + // arc-paste deployment instead of the public default. + // Resolution precedence in `arc share create --share`: + // 1. --server flag (highest) + // 2. this config field + // 3. $ARC_SHARE_SERVER + // 4. https://arcplanner.sentiolabs.io (built-in default) + ShareServer string `json:"share_server,omitempty"` } // ProjectSource indicates how the project was resolved diff --git a/cmd/arc/share.go b/cmd/arc/share.go new file mode 100644 index 0000000..9432c4c --- /dev/null +++ b/cmd/arc/share.go @@ -0,0 +1,882 @@ +// Package main extends the arc CLI with `arc share` commands for creating +// and managing zero-knowledge encrypted plan shares. +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/sentiolabs/arc/internal/paste" + "github.com/sentiolabs/arc/internal/sharesconfig" +) + +// --- plaintext schemas (mirror web/src/lib/paste/types.ts) --- + +type planPlaintext struct { + Version int `json:"version"` + Markdown string `json:"markdown"` + Title string `json:"title,omitempty"` + AuthorName string `json:"author_name,omitempty"` + CreatedAt string `json:"created_at"` +} + +type commentEvent struct { + Kind string `json:"kind"` + ID string `json:"id"` + AuthorName string `json:"author_name"` + CommentType string `json:"comment_type"` + // Action is the reviewer's primary intent: "comment" (default) or + // "delete" (strikethrough — body may be empty since the strikethrough + // IS the action). Preserved on round-trip so consumers like + // `arc share comments --json` can distinguish deletion requests from + // regular comments. Mirrors the AnnotationAction type in the SPA + // (web/src/lib/paste/types.ts). + Action string `json:"action,omitempty"` + Severity string `json:"severity,omitempty"` + Body string `json:"body"` + SuggestedText string `json:"suggested_text,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Anchor any `json:"anchor"` + CreatedAt string `json:"created_at"` +} + +type resolutionEvent struct { + Kind string `json:"kind"` + ID string `json:"id"` + CommentID string `json:"comment_id"` + Status string `json:"status"` + Reply string `json:"reply,omitempty"` + AuthorName string `json:"author_name"` + CreatedAt string `json:"created_at"` +} + +type approvalEvent struct { + Kind string `json:"kind"` // always "approval" + ID string `json:"id"` + AuthorName string `json:"author_name"` + CreatedAt string `json:"created_at"` +} + +// commentEntry is the in-flight aggregation of a comment + its resolution +// status, used internally by printComments / emitBundle. Lives at package +// scope so both functions can refer to the same type. +type commentEntry struct { + comment commentEvent + status string + reply string +} + +// editEvent is a reviewer revising their own annotation. Replay merges the +// supplied fields onto the target comment in chronological order, gated on +// the edit's author_name matching the original comment's. See the +// EditEvent docstring in web/src/lib/paste/types.ts for the field semantics. +// +// Only `body`, `suggested_text`, and `comment_type` are editable. Pointer +// fields distinguish "field omitted (keep)" from "field set to empty +// (clear)" — Go zero values would conflate the two. +type editEvent struct { + Kind string `json:"kind"` // always "edit" + ID string `json:"id"` + CommentID string `json:"comment_id"` + AuthorName string `json:"author_name"` + Body *string `json:"body,omitempty"` + SuggestedText *string `json:"suggested_text,omitempty"` + CommentType *string `json:"comment_type,omitempty"` + CreatedAt string `json:"created_at"` +} + +// --- commands --- + +var shareCmd = &cobra.Command{ + Use: "share", + Short: "Create and manage encrypted plan shares", +} + +var shareCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Encrypt a plan and create a share", + Args: cobra.ExactArgs(1), + RunE: runShareCreate, +} + +var shareListCmd = &cobra.Command{ + Use: "list", + Short: "List shares known to this machine", + RunE: runShareList, +} + +var shareShowCmd = &cobra.Command{ + Use: "show ", + Short: "Decrypt and print plan content", + Args: cobra.ExactArgs(1), + RunE: runShareShow, +} + +var shareCommentsCmd = &cobra.Command{ + Use: "comments ", + Short: "Fetch and decrypt comments for a share", + Args: cobra.ExactArgs(1), + RunE: runShareComments, +} + +var sharePullCmd = &cobra.Command{ + Use: "pull ", + Short: "Pull comments (alias for `comments` with --accepted-only by default)", + Args: cobra.ExactArgs(1), + RunE: runSharePull, +} + +var shareApproveCmd = &cobra.Command{ + Use: "approve ", + Short: "Mark the share as approved", + Args: cobra.ExactArgs(1), + RunE: runShareApprove, +} + +var shareUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Replace the encrypted plan content (uses edit_token from shares.json)", + // `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 saved +// shares.json registry 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 edit_token from shares.json)", + Args: cobra.ExactArgs(1), + RunE: runShareDelete, +} + +var ( + shareCreateLocal bool + shareCreateRemote bool + shareCreateServer string + shareCreateAuthor string + shareCreateTitle string + shareCommentsAccepted bool + shareCommentsJSON bool +) + +func init() { + shareCreateCmd.Flags().BoolVar(&shareCreateLocal, "local", false, "Use the local arc-server") + shareCreateCmd.Flags().BoolVar(&shareCreateRemote, "share", false, "Use the configured remote share 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`). "+ + "Reviewers entering this exact name gain Accept/Resolve/Reject controls.") + 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") + + 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(shareCreateLocal, 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 + } + fmt.Printf("Share URL: %s/share/%s#k=%s\n", strings.TrimRight(server, "/"), resp.ID, keyB64) + fmt.Printf("Edit token: %s (saved in ~/.arc/shares.json — keep safe)\n", resp.EditToken) + return nil +} + +func runShareList(cmd *cobra.Command, args []string) error { + f, err := sharesconfig.Load() + if err != nil { + return err + } + 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 { + 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 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 ~/.arc/shares.json", 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 == "" { + return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json", id) + } + if err := deleteShare(server, id, s.EditToken); err != nil { + return 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 the only thing that lets the share UI distinguish the +// plan owner from reviewers — when a visitor enters this exact name in the +// SPA's name prompt, they get Accept/Resolve/Reject controls. Without it, +// nobody is recognized as the author and the controls stay hidden for all. +func resolveAuthor(flag string) string { + if s := strings.TrimSpace(flag); s != "" { + return s + } + if cfg, err := loadConfig(); err == nil { + if s := strings.TrimSpace(cfg.ShareAuthor); 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. +func resolveServer(_, share bool, override string) (server, kind string) { + if s := strings.TrimSpace(override); s != "" { + return s, shareKindShared + } + if share { + 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 { + if s := strings.TrimSpace(cfg.ShareServer); s != "" { + 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.ServerURL == "" { + return "http://localhost:7432" + } + return cfg.ServerURL +} + +// 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 := replayEvents(decoded, plan.AuthorName) + entries := buildCommentEntries(comments, resolutions, 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) { + comments := map[string]commentEvent{} + resolutions := map[string]resolutionEvent{} + for _, d := range events { + switch d.kind { + case "comment": + applyCommentEvent(d.raw, comments) + case "resolution": + applyResolutionEvent(d.raw, planAuthor, resolutions) + case "edit": + applyEditEvent(d.raw, planAuthor, comments) + } + } + return comments, resolutions +} + +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, + 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 { + status := "open" + reply := "" + if res, ok := resolutions[cid]; ok { + status = res.Status + reply = res.Reply + } + if acceptedOnly && status != "accepted" { + continue + } + entries = append(entries, commentEntry{comment: c, status: status, reply: reply}) + } + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].comment.CreatedAt < entries[j].comment.CreatedAt + }) + return entries +} + +func printCommentEntries(entries []commentEntry) { + for _, e := range entries { + // Mark deletes visually so they don't get mistaken for empty-body comments. + prefix := "" + if e.comment.Action == "delete" { + prefix = "[delete] " + } + fmt.Printf("[%s] %s%s (%s): %s\n", + e.status, prefix, e.comment.AuthorName, e.comment.CommentType, e.comment.Body) + } +} + +// shareBundle is the JSON shape emitted by `arc share comments --json`. +// +// Designed for LLM consumption: a single object with everything an agent +// needs to apply review feedback. +// +// Plan content is exposed via exactly one of two fields: +// - `file` — absolute path on disk. Set when the share is registered in +// shares.json 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 shares.json 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 shares.json 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 ~/.arc/shares.json. +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 ~/.arc/shares.json 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 new file mode 100644 index 0000000..83baedf --- /dev/null +++ b/cmd/arc/share_test.go @@ -0,0 +1,677 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "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/paste" + pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" + "github.com/sentiolabs/arc/internal/sharesconfig" +) + +func startTestPasteServer(t *testing.T) *httptest.Server { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := pastesqlite.Apply(context.Background(), db); err != nil { + t.Fatalf("apply migrations: %v", err) + } + e := echo.New() + paste.NewHandlers(pastesqlite.New(db)).Register(e.Group("/api/paste")) + return httptest.NewServer(e) +} + +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) + } +} + +// 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). +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, "cli-config.json") + body := `{"server_url":"http://localhost:7432"` + if author != "" { + body += `,"share_author":"` + author + `"` + } + body += `}` + 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 + share bool + local 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", + share: true, flag: "https://from-flag.example", + wantURL: "https://from-flag.example", wantKind: shareKindShared, + }, + { + // The override flag is intentionally global — it forces shared mode + // regardless of which boolean flags are set, mirroring the previous + // behavior. Locked in here so it doesn't silently drift. + name: "flag wins even without --share", + config: "https://from-config.example", + local: true, 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", + share: true, + wantURL: "https://from-config.example", wantKind: shareKindShared, + }, + { + name: "env wins when config empty", + env: "https://from-env.example", share: true, + wantURL: "https://from-env.example", wantKind: shareKindShared, + }, + { + name: "falls back to built-in default", + share: true, + wantURL: builtinDefault, wantKind: shareKindShared, + }, + { + name: "flag whitespace is trimmed and treated as empty", + config: "https://from-config.example", + share: 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.local, tc.share, tc.flag) + if got != tc.wantURL || kind != tc.wantKind { + t.Errorf("resolveServer = (%q, %q), want (%q, %q)", got, kind, tc.wantURL, tc.wantKind) + } + }) + } +} + +// writeShareServerConfig points the global configPath at a temp cli-config.json +// containing the given share_server (omitted entirely when empty). +func writeShareServerConfig(t *testing.T, server string) { + t.Helper() + dir := t.TempDir() + configPath = filepath.Join(dir, "cli-config.json") + body := `{"server_url":"http://localhost:7432"` + if server != "" { + body += `,"share_server":"` + server + `"` + } + body += `}` + if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} diff --git a/docs/review_howto.md b/docs/review_howto.md new file mode 100644 index 0000000..9c7f847 --- /dev/null +++ b/docs/review_howto.md @@ -0,0 +1,178 @@ +# 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. + +## 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/docs/runbooks/paste-server.md b/docs/runbooks/paste-server.md new file mode 100644 index 0000000..5530c81 --- /dev/null +++ b/docs/runbooks/paste-server.md @@ -0,0 +1,230 @@ +# 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 --local +# → Share URL: http://localhost:7432/share/#k= +# → Edit token: (saved in ~/.arc/shares.json) + +./bin/arc share list # see all known shares +ls -la ~/.arc/shares.json # verify file mode 0600 +``` + +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** — you'll need to type it back in the UI to claim the author role. 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 printed URL in Chrome/Firefox. + +1. **Name prompt** appears on first comment — type the **same name** that was embedded as the author at create time (i.e. your `git config user.name` output, or whatever you passed to `--author`). The reviewer-name chip in the header will read ` · author` once the names match. +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 comment appears in the sidebar +6. As the author (your localStorage name matches `plan.author_name`), you'll see **Accept / Resolve / Reject** controls. Try Accept on one comment, Reject (with reply) on another, and Resolve on a third. If the controls don't appear, double-check that the name in the header chip matches the value in `git config user.name` (or whatever you passed to `--author`) — the comparison is case- and whitespace-sensitive. +7. As a reviewer (your localStorage name does NOT match the plan's author), 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; this is the path that lets the author shape feedback for downstream LLM consumption without round-trips with the reviewer. + +### 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, open the same URL in an **incognito window** (or a different browser entirely). Incognito gets a fresh `localStorage`, so: + +1. The name prompt fires again — type any name **other than** the embedded author name (e.g. "Reviewer-2"). The chip in the header should NOT show `· author`. +2. Post a few comments +3. Refresh the author's window → new comments replay in via `replayEvents()` + +The author's resolution events still apply (their `author_name` matches the plan's). Reviewer-2's comments cannot be marked as `accepted` by Reviewer-2 itself, even if they tried — the client filters out resolution events whose `author_name` doesn't match `plan.author_name`. + +## 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 (uses the `arc-paste/Dockerfile` scratch image and a named volume): + +```bash +docker compose -f arc-paste/compose.yaml up -d --build +docker compose -f arc-paste/compose.yaml logs -f +``` + +The compose file binds host port 7433, persists SQLite to the `arc-paste-data` named volume, and sets `restart: unless-stopped`. Note: the runtime image is `scratch`, so there's no in-container healthcheck — pair with an external probe (Cloudflare health check, Uptime Kuma, etc.) for production. + +### Create a shared paste pointed at the standalone server + +```bash +# Terminal 2 +./bin/arc share create /tmp/test-plan.md --share --server http://localhost:7433 +# → Share URL: http://localhost:7433/share/#k= +``` + +That URL is what you'd send to a real reviewer (Slack, email, etc.). It contains everything they need: the share id and the decryption key in the fragment. + +### 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 --share --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 --share` 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` +- **Shared review** → invokes `arc share create --share` +- **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, even when typing the "right" name | Plan was created without an author name (`arc share create` was run pre-fix, or `git config user.name` was empty and no `--author` flag passed). `plan.author_name` is empty, so `isAuthor` is `false` for every reviewer | Recreate the share with `--author "Your Name"` (or set `git config user.name` first), then enter that exact name in the SPA prompt | +| `/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/internal/api/paste_routes.go b/internal/api/paste_routes.go new file mode 100644 index 0000000..fc32b30 --- /dev/null +++ b/internal/api/paste_routes.go @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..ebcfacf --- /dev/null +++ b/internal/api/paste_routes_test.go @@ -0,0 +1,87 @@ +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(Config{ + 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("= 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 new file mode 100644 index 0000000..521532c --- /dev/null +++ b/internal/paste/anchor_test.go @@ -0,0 +1,143 @@ +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 new file mode 100644 index 0000000..0146e52 --- /dev/null +++ b/internal/paste/cmd/genxlang/main.go @@ -0,0 +1,54 @@ +// 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 new file mode 100644 index 0000000..2b6d35d --- /dev/null +++ b/internal/paste/crypto.go @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..323a5af --- /dev/null +++ b/internal/paste/crypto_test.go @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..7d3790b --- /dev/null +++ b/internal/paste/crypto_xlang_test.go @@ -0,0 +1,85 @@ +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 new file mode 100644 index 0000000..27989ce --- /dev/null +++ b/internal/paste/handlers.go @@ -0,0 +1,237 @@ +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 new file mode 100644 index 0000000..70e3e57 --- /dev/null +++ b/internal/paste/handlers_test.go @@ -0,0 +1,249 @@ +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 new file mode 100644 index 0000000..162d1e9 --- /dev/null +++ b/internal/paste/sqlite/migrations.go @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..9c476bb --- /dev/null +++ b/internal/paste/sqlite/migrations/001_init.sql @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..f92ec88 --- /dev/null +++ b/internal/paste/sqlite/store.go @@ -0,0 +1,133 @@ +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 new file mode 100644 index 0000000..5b43c3a --- /dev/null +++ b/internal/paste/sqlite/store_test.go @@ -0,0 +1,123 @@ +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 new file mode 100644 index 0000000..5a8fdc1 --- /dev/null +++ b/internal/paste/storage.go @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..913aefb --- /dev/null +++ b/internal/paste/testdata/README.md @@ -0,0 +1,15 @@ +# 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 new file mode 100644 index 0000000..7565d6f --- /dev/null +++ b/internal/paste/testdata/xlang_fixtures.json @@ -0,0 +1,32 @@ +[ + { + "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 new file mode 100644 index 0000000..b0f0f2c --- /dev/null +++ b/internal/paste/types.go @@ -0,0 +1,46 @@ +// 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 new file mode 100644 index 0000000..deeb1c0 --- /dev/null +++ b/internal/paste/types_test.go @@ -0,0 +1,33 @@ +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 1384590..a355be6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,6 +67,7 @@ func Run(cfg Config) error { server := api.New(api.Config{ Address: cfg.Address, Store: store, + DB: store.DB(), }) // Start server in goroutine diff --git a/internal/sharesconfig/sharesconfig.go b/internal/sharesconfig/sharesconfig.go new file mode 100644 index 0000000..736d4b0 --- /dev/null +++ b/internal/sharesconfig/sharesconfig.go @@ -0,0 +1,133 @@ +// Package sharesconfig manages the registry of paste shares the user has +// created, stored at ~/.arc/shares.json with file mode 0600. +package sharesconfig + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +// File permission bits for the shares registry. shares.json holds edit +// tokens, so it must not be world-readable. +const ( + dirMode os.FileMode = 0o700 + fileMode os.FileMode = 0o600 +) + +// ErrShareNotFound is returned by Find when no share matches the given ID. +var ErrShareNotFound = errors.New("share not found") + +// Share holds the metadata for a single paste share created by this machine. +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 is the top-level structure of ~/.arc/shares.json. +type File struct { + Shares []Share `json:"shares"` +} + +// defaultPath returns the path to ~/.arc/shares.json. +func defaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".arc", "shares.json"), nil +} + +// Load reads the shares file from disk. Returns an empty File if the file does +// not exist yet. +func Load() (*File, error) { + path, err := defaultPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return &File{}, nil + } + if err != nil { + return nil, err + } + var f File + if err := json.Unmarshal(data, &f); err != nil { + return nil, err + } + return &f, nil +} + +// Save writes the File to disk at ~/.arc/shares.json with mode 0600. The +// parent directory is created with mode 0700 if it does not exist. +func Save(f *File) error { + path, err := defaultPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil { + return err + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, fileMode) +} + +// Add upserts a Share into the registry. If a share with the same ID already +// exists it is replaced; otherwise the share is appended. +func Add(s Share) error { + f, err := Load() + if err != nil { + return err + } + for i, existing := range f.Shares { + if existing.ID == s.ID { + f.Shares[i] = s + return Save(f) + } + } + f.Shares = append(f.Shares, s) + return Save(f) +} + +// Find returns the Share with the given ID, or ErrShareNotFound if no entry +// matches. Callers may also use errors.Is(err, ErrShareNotFound) to branch. +func Find(id string) (*Share, error) { + f, err := Load() + if err != nil { + return nil, err + } + for _, s := range f.Shares { + if s.ID == id { + return &s, nil + } + } + return nil, ErrShareNotFound +} + +// Remove deletes the share with the given ID from the registry. It is a no-op +// if the ID does not exist. +func Remove(id string) error { + f, err := Load() + if err != nil { + return err + } + out := f.Shares[:0] + for _, s := range f.Shares { + if s.ID != id { + out = append(out, s) + } + } + f.Shares = out + return Save(f) +} diff --git a/internal/sharesconfig/sharesconfig_test.go b/internal/sharesconfig/sharesconfig_test.go new file mode 100644 index 0000000..bf22230 --- /dev/null +++ b/internal/sharesconfig/sharesconfig_test.go @@ -0,0 +1,49 @@ +package sharesconfig_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/sentiolabs/arc/internal/sharesconfig" +) + +func TestAddAndFind(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := sharesconfig.Share{ + ID: "abc", Kind: "local", URL: "http://x", + KeyB64Url: "k", EditToken: "t", CreatedAt: time.Now(), + } + if err := sharesconfig.Add(s); err != nil { + t.Fatal(err) + } + found, err := sharesconfig.Find("abc") + if err != nil || found == nil || found.ID != "abc" { + t.Errorf("unexpected: %+v err=%v", found, err) + } +} + +func TestFileMode0600(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + _ = sharesconfig.Add(sharesconfig.Share{ID: "x", Kind: "local", CreatedAt: time.Now()}) + info, err := os.Stat(filepath.Join(home, ".arc", "shares.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("expected mode 0600, got %o", info.Mode().Perm()) + } +} + +func TestRemove(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + _ = sharesconfig.Add(sharesconfig.Share{ID: "a", CreatedAt: time.Now()}) + _ = sharesconfig.Add(sharesconfig.Share{ID: "b", CreatedAt: time.Now()}) + _ = sharesconfig.Remove("a") + f, _ := sharesconfig.Load() + if len(f.Shares) != 1 || f.Shares[0].ID != "b" { + t.Errorf("after remove, got %+v", f.Shares) + } +} diff --git a/internal/storage/sqlite/store.go b/internal/storage/sqlite/store.go index 8de1e42..a76dc2d 100644 --- a/internal/storage/sqlite/store.go +++ b/internal/storage/sqlite/store.go @@ -10,6 +10,7 @@ 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" @@ -84,7 +85,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(_ context.Context) error { +func (s *Store) initSchema(ctx context.Context) error { backupPath, err := backupForMigration(s.db, s.path) if err != nil { // Non-fatal: migrating without a backup is better than not migrating @@ -112,9 +113,21 @@ func (s *Store) initSchema(_ 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). +func (s *Store) DB() *sql.DB { + return s.db +} + // Close closes the database connection. func (s *Store) Close() error { return s.db.Close() diff --git a/web/bun.lock b/web/bun.lock index da2c14b..5fc526f 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -17,6 +17,7 @@ "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@types/bun": "^1.3.10", "@types/dompurify": "^3.2.0", @@ -35,6 +36,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.9.3", "vite": "^7.2.6", + "vitest": "^4.1.5", }, }, }, @@ -291,12 +293,18 @@ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@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=="], @@ -335,6 +343,20 @@ "@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=="], @@ -351,6 +373,8 @@ "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=="], @@ -365,6 +389,8 @@ "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=="], @@ -387,6 +413,8 @@ "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=="], @@ -421,6 +449,8 @@ "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=="], @@ -447,8 +477,12 @@ "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=="], @@ -627,6 +661,8 @@ "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=="], @@ -645,7 +681,7 @@ "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], - "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -685,12 +721,18 @@ "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=="], @@ -709,8 +751,14 @@ "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=="], @@ -757,6 +805,8 @@ "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=="], @@ -767,6 +817,8 @@ "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=="], @@ -821,6 +873,8 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/web/package.json b/web/package.json index 86924a4..b9ea30c 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": "playwright test", + "test": "vitest run", "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" @@ -26,6 +26,7 @@ "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@types/bun": "^1.3.10", "@types/dompurify": "^3.2.0", @@ -43,7 +44,8 @@ "svelte-check": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "^5.9.3", - "vite": "^7.2.6" + "vite": "^7.2.6", + "vitest": "^4.1.5" }, "dependencies": { "isomorphic-dompurify": "^3.0.0", diff --git a/web/playwright.e2e.config.ts b/web/playwright.e2e.config.ts index 4ac2aca..189224d 100644 --- a/web/playwright.e2e.config.ts +++ b/web/playwright.e2e.config.ts @@ -11,13 +11,13 @@ export default defineConfig({ reporter: [['html', { open: 'never' }]], use: { baseURL: 'http://localhost:7433', - trace: 'on-first-retry', + trace: 'on-first-retry' }, projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], + use: { ...devices['Desktop Chrome'] } + } + ] // No webServer block — test server is managed externally by docker compose }); diff --git a/web/src/app.css b/web/src/app.css index 9e8272c..316be49 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,8 +1,11 @@ /* Import distinctive fonts - must come before @import "tailwindcss" */ -@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Inter+Tight:wght@400;500;600;700&display=swap"); @import "tailwindcss"; +/* Tailwind v4 plugin registration (CSS-first config) */ +@plugin "@tailwindcss/typography"; + /* Custom theme - Refined Terminal aesthetic */ @theme { /* Typography */ @@ -676,3 +679,412 @@ animation: spin 1s linear infinite; } } + +/* ========================================================================== + Dark Editorial — scoped tokens for /share/[id] + Aesthetic: a manuscript on a copyeditor's desk, at night. + Newsreader serif body, Inter Tight UI, ink-color metaphor. + These tokens only apply inside `.share-page` so the rest of arc's UI + keeps its Refined Terminal aesthetic. + ========================================================================== */ + +/* + * Editorial spread layout — centered-doc composition. + * + * Two-column grid: [doc 1fr | rail 360]. The rail stays a fixed + * 360px notebook margin on the right. The doc-area fills the rest + * and centers its inner content (max-width 760px), so the gap on + * the left of the doc always equals the gap between the doc's right + * edge and the rail's left edge. + * + * Behavior across viewport widths: + * - ultrawide (1920px viewport): doc-area is 1560px, doc is 760px, + * gap is 400px on each side — visually centered between the + * viewport edge and the rail. + * - typical (1200px viewport): doc-area is 840px, doc is 760px, + * gap is 40px on each side — close to flush but not pinned. + * - narrow (≤1000px viewport): the doc fills its column, gaps + * collapse to zero. + * + * `padding-inline` on the inner div keeps the prose from running into + * the column edges at any width, while `mx-auto` does the centering. + */ +.share-page { + grid-template-columns: minmax(0, 1fr) 360px; +} +/* + * Doc width — fluid clamp so the prose breathes on wide viewports + * without ever shrinking the comfortable narrow-screen experience. + * + * floor 47.5rem (760px) — same width as before; viewports already + * comfortable today don't change. + * pref 80% — 80% of doc-area (= viewport minus rail); + * grows smoothly with available space. + * ceil 60rem (960px) — readable upper bound. Past ~95ch the eye + * saccade between line-end and next-line + * gets too long for comfortable reading, + * even though monospace code blocks tolerate + * the extra width fine. + * + * `%` is relative to the parent's content box (the doc-area = `1fr` + * column), so we don't have to subtract the rail width by hand — + * the layout already did that for us. + */ +.share-page .doc-inner { + max-width: clamp(47.5rem, 80%, 60rem); + margin-left: auto; + margin-right: auto; +} + +/* + * Token system — `--ink-*` names kept for backward-compat with the + * existing annotation UI selectors, but VALUES now point at arc's + * standard `--color-*` tokens so the share page reads the same as + * /planner and the rest of the app. Annotation-specific tones + * (comment / delete / praise) keep their distinctive review-domain + * hues since they encode semantic meaning, not just decoration. + */ +.share-page { + /* Surfaces & text — alias to arc's Refined Terminal palette. */ + --ink-paper: var(--color-surface-900); + --ink-paper-raised: var(--color-surface-800); + --ink-paper-edge: var(--color-surface-700); + --ink-text: var(--color-text-primary); + --ink-text-muted: var(--color-text-secondary); + --ink-text-faint: var(--color-text-muted); + --ink-rule: var(--color-border); + + /* Annotation inks — these are review-domain semantics (highlight = + amber, deletion = red, praise = green) and stay distinct from + surface chrome. Kept in oklch with explicit alphas so the badges + read clearly against any surface. */ + --ink-comment: oklch(0.78 0.16 75); /* warm amber highlight */ + --ink-comment-bg: oklch(0.78 0.16 75 / 0.16); + --ink-comment-edge: oklch(0.78 0.16 75 / 0.55); + --ink-delete: var(--color-status-blocked); + --ink-delete-bg: oklch(from var(--color-status-blocked) l c h / 0.18); + --ink-delete-edge: oklch(from var(--color-status-blocked) l c h / 0.55); + --ink-praise: var(--color-status-open); + --ink-praise-bg: oklch(from var(--color-status-open) l c h / 0.16); + + background: var(--ink-paper); + color: var(--ink-text); + min-height: 100vh; +} + +.share-page .ui-sans { + font-family: "Inter Tight", ui-sans-serif, system-ui, sans-serif; + font-feature-settings: "kern", "liga", "ss01", "cv11"; +} + +.share-page .ui-mono { + font-family: "JetBrains Mono", ui-monospace, monospace; +} + +/* + * Document column — mirrors the planner's `.markdown` styles so + * /share/[id] reads as the same product as /planner. Inherits + * Instrument Sans from `html` (no font-family override here). + */ +.share-page .doc { + font-size: 1rem; + line-height: 1.7; + color: var(--color-text-primary); +} +.share-page .doc h1, +.share-page .doc h2, +.share-page .doc h3, +.share-page .doc h4, +.share-page .doc h5, +.share-page .doc h6 { + color: var(--color-text-primary); + font-weight: 600; + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.25; +} +.share-page .doc h1:first-child, +.share-page .doc h2:first-child, +.share-page .doc h3:first-child, +.share-page .doc h4:first-child, +.share-page .doc h5:first-child, +.share-page .doc h6:first-child { + margin-top: 0; +} +.share-page .doc h1 { + font-size: 1.5em; +} +.share-page .doc h2 { + font-size: 1.25em; + padding-bottom: 0.25em; + border-bottom: 1px solid var(--color-border-subtle); +} +.share-page .doc h3 { + font-size: 1.1em; +} +.share-page .doc h4, +.share-page .doc h5, +.share-page .doc h6 { + font-size: 1em; +} +.share-page .doc p { + margin-bottom: 0.75em; + color: var(--color-text-secondary); + line-height: 1.7; +} +.share-page .doc ul, +.share-page .doc ol { + margin: 0.75em 0; + padding-left: 1.5em; + color: var(--color-text-secondary); +} +.share-page .doc ul { + list-style-type: disc; +} +.share-page .doc ol { + list-style-type: decimal; +} +.share-page .doc li { + margin-bottom: 0.25em; + line-height: 1.7; +} +.share-page .doc strong { + color: var(--color-text-primary); +} +.share-page .doc em { + font-style: italic; +} +.share-page .doc code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--color-surface-700); + color: var(--color-primary-300); + padding: 0.15em 0.4em; + border-radius: 3px; +} +/* + * Code blocks come through shiki (see src/lib/markdown.ts) which sets its + * own `background-color` and `color` via inline styles — those take + * precedence over our CSS by specificity. We keep border, padding, + * border-radius, and font-size for layout consistency, and explicitly + * DON'T set `background` so shiki's github-dark-dimmed theme can shine + * through. The `.shiki` class is shiki's own marker; targeting both + * `pre` and `pre.shiki` keeps non-highlighted text-blocks looking + * consistent. + */ +/* + * Code blocks come through shiki (see src/lib/markdown.ts) which sets + * its own `background-color` and `color` via inline styles — those + * win over our CSS by specificity. We provide border + padding + + * font-size for layout, and only set `background` for plain (no-lang) + * fences via the `:not(.shiki)` escape hatch. + */ +.share-page .doc pre { + font-family: var(--font-mono); + font-size: 0.85em; + padding: 1em; + border-radius: 6px; + border: 1px solid var(--color-border); + overflow-x: auto; + line-height: 1.6; + margin: 1em 0; +} +.share-page .doc pre:not(.shiki) { + background: var(--color-surface-900); +} +.share-page .doc pre code { + background: transparent; + padding: 0; + border-radius: 0; + color: inherit; +} +.share-page .doc blockquote { + border-left: 3px solid var(--color-primary-600); + background: var(--color-surface-800); + padding: 0.75em 1em; + margin: 1em 0; + font-style: italic; + color: var(--color-text-secondary); +} +.share-page .doc blockquote p:last-child { + margin-bottom: 0; +} +.share-page .doc hr { + border: none; + border-top: 1px solid var(--color-border-subtle); + margin: 1.5em 0; +} +.share-page .doc a { + color: var(--color-primary-400); + text-decoration: none; +} +.share-page .doc a:hover { + text-decoration: underline; +} +.share-page .doc table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 0.875em; +} +.share-page .doc th { + background: var(--color-surface-700); + font-weight: 600; + text-align: left; + padding: 0.5em 0.75em; + border-bottom: 2px solid var(--color-border); +} +.share-page .doc td { + padding: 0.5em 0.75em; + border-bottom: 1px solid var(--color-border-subtle); + color: var(--color-text-secondary); +} +.share-page .doc tr:hover td { + background: var(--color-surface-800); +} + +/* Inline annotation marks (added by inline-annotations.ts after render) */ +.share-page mark.anno-comment { + background: var(--ink-comment-bg); + color: var(--ink-text); + border-bottom: 1.5px dotted var(--ink-comment); + padding: 0 1px; + border-radius: 1px; + cursor: pointer; + transition: background 120ms ease-out; +} +.share-page mark.anno-comment:hover, +.share-page mark.anno-comment.is-active { + background: oklch(from var(--ink-comment) l c h / 0.3); +} +.share-page mark.anno-delete { + background: var(--ink-delete-bg); + color: var(--ink-text-muted); + text-decoration: line-through; + text-decoration-color: var(--ink-delete); + text-decoration-thickness: 2px; + border-bottom: 1.5px wavy var(--ink-delete-edge); + padding: 0 1px; + border-radius: 1px; + cursor: pointer; +} + +/* Native selection highlight */ +.share-page ::selection { + background: oklch(from var(--ink-comment) l c h / 0.35); + color: var(--ink-text); +} + +/* Floating toolbar */ +.share-page .floating-toolbar { + background: var(--ink-paper-raised); + border: 1px solid var(--ink-rule); + border-radius: 8px; + box-shadow: + 0 1px 0 oklch(1 0 0 / 0.04) inset, + 0 8px 24px oklch(0 0 0 / 0.45), + 0 2px 8px oklch(0 0 0 / 0.35); + backdrop-filter: blur(8px); +} +.share-page .floating-toolbar button { + font-family: "Inter Tight", ui-sans-serif, sans-serif; + transition: + background 120ms ease-out, + color 120ms ease-out; +} + +/* Annotation card type chips */ +.share-page .chip-comment { + color: var(--ink-comment); + background: var(--ink-comment-bg); + border: 1px solid var(--ink-comment-edge); +} +.share-page .chip-delete { + color: var(--ink-delete); + background: var(--ink-delete-bg); + border: 1px solid var(--ink-delete-edge); +} +.share-page .chip-praise { + color: var(--ink-praise); + background: var(--ink-praise-bg); +} + +/* Annotation cards */ +.share-page .anno-card { + background: var(--ink-paper-raised); + border: 1px solid var(--ink-rule); + border-radius: 8px; + transition: + border-color 150ms ease-out, + transform 150ms ease-out; +} +.share-page .anno-card:hover { + border-color: oklch(from var(--ink-rule) calc(l + 0.05) c h); +} +.share-page .anno-card.is-active { + border-color: var(--ink-comment-edge); +} +.share-page .anno-card .quote { + /* Italic kept as a typographic marker for "this is a quoted span" — + the rest of the card is Instrument Sans like the planner. */ + font-style: italic; + color: var(--ink-text-muted); + background: var(--ink-paper); + border-left: 2px solid var(--ink-text-faint); + padding: 0.5rem 0.75rem; + border-radius: 0 4px 4px 0; +} +.share-page .anno-card .body { + background: var(--ink-paper); + border: 1px solid var(--ink-rule); + border-radius: 4px; + padding: 0.5rem 0.75rem; + color: var(--ink-text); +} + +/* Drop-cap animation entrance */ +@keyframes anno-card-in { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +.share-page .anno-card { + animation: anno-card-in 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes toolbar-in { + from { + opacity: 0; + transform: translate(-50%, 12px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} +.share-page .floating-toolbar { + animation: toolbar-in 150ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.share-page .doc-area { + /* Flat surface to match the planner's clean Refined Terminal look. */ + background: var(--color-surface-900); +} + +/* Reviewer name chip */ +.share-page .name-chip { + font-family: "Inter Tight", ui-sans-serif, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--ink-text-muted); + background: var(--ink-paper-raised); + border: 1px solid var(--ink-rule); + padding: 0.25rem 0.625rem; + border-radius: 9999px; +} diff --git a/web/src/lib/api/ai.test.ts b/web/src/lib/api/ai.test.ts index da42599..57b57f6 100644 --- a/web/src/lib/api/ai.test.ts +++ b/web/src/lib/api/ai.test.ts @@ -28,9 +28,7 @@ describe('AI API client - project-scoped paths', () => { }); test('getAIAgent calls correct project-scoped API endpoint', () => { - expect(apiSource).toContain( - "'/projects/{projectId}/ai/sessions/{sessionId}/agents/{agentId}'" - ); + expect(apiSource).toContain("'/projects/{projectId}/ai/sessions/{sessionId}/agents/{agentId}'"); expect(apiSource).toContain('path: { projectId, sessionId, agentId }'); }); @@ -54,9 +52,7 @@ describe('AI API client - project-scoped paths', () => { }); test('deleteAISession uses project-scoped API path', () => { - expect(apiSource).toContain( - "api.DELETE('/projects/{projectId}/ai/sessions/{sessionId}'" - ); + expect(apiSource).toContain("api.DELETE('/projects/{projectId}/ai/sessions/{sessionId}'"); }); test('batchDeleteAISessions accepts projectId as first parameter', () => { diff --git a/web/src/lib/api/ai.ts b/web/src/lib/api/ai.ts index 4139b56..95566e0 100644 --- a/web/src/lib/api/ai.ts +++ b/web/src/lib/api/ai.ts @@ -118,12 +118,9 @@ export async function listAIAgents( projectId: string, sessionId: string ): Promise { - const { data, error } = await api.GET( - '/projects/{projectId}/ai/sessions/{sessionId}/agents', - { - params: { path: { projectId, sessionId } } - } - ); + const { data, error } = await api.GET('/projects/{projectId}/ai/sessions/{sessionId}/agents', { + params: { path: { projectId, sessionId } } + }); if (error) { if (typeof error === 'object' && error !== null && 'error' in error) { throw new Error(String((error as { error: string }).error)); diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 7f306dd..46dd5f9 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -208,10 +208,7 @@ export async function getIssue( export type CreateIssueRequest = components['schemas']['CreateIssueRequest']; -export async function createIssue( - projectId: string, - request: CreateIssueRequest -): Promise { +export async function createIssue(projectId: string, request: CreateIssueRequest): Promise { const { data, error } = await api.POST('/projects/{projectId}/issues', { params: { path: { projectId } }, body: request @@ -325,12 +322,9 @@ export async function removeLabelFromIssue( issueId: string, labelName: string ): Promise { - const { error } = await api.DELETE( - '/projects/{projectId}/issues/{issueId}/labels/{labelName}', - { - params: { path: { projectId, issueId, labelName } } - } - ); + const { error } = await api.DELETE('/projects/{projectId}/issues/{issueId}/labels/{labelName}', { + params: { path: { projectId, issueId, labelName } } + }); if (error) handleError(error); } @@ -348,24 +342,17 @@ export async function createComment( issueId: string, text: string ): Promise { - const { data, error } = await api.POST( - '/projects/{projectId}/issues/{issueId}/comments', - { - params: { path: { projectId, issueId } }, - body: { text } - } - ); + const { data, error } = await api.POST('/projects/{projectId}/issues/{issueId}/comments', { + params: { path: { projectId, issueId } }, + body: { text } + }); if (error) handleError(error); if (!data) throw new Error('Failed to create comment'); return data; } // Event APIs -export async function getEvents( - projectId: string, - issueId: string, - limit = 50 -): Promise { +export async function getEvents(projectId: string, issueId: string, limit = 50): Promise { const { data, error } = await api.GET('/projects/{projectId}/issues/{issueId}/events', { params: { path: { projectId, issueId }, @@ -464,7 +451,11 @@ export async function listPlanComments(planId: string): Promise { return data ?? []; } -export async function createPlanComment(planId: string, content: string, lineNumber?: number): Promise { +export async function createPlanComment( + planId: string, + content: string, + lineNumber?: number +): Promise { const { data, error } = await api.POST('/plans/{planId}/comments', { params: { path: { planId } }, body: { content, line_number: lineNumber ?? null } @@ -475,10 +466,7 @@ export async function createPlanComment(planId: string, content: string, lineNum } // Team Context APIs -export async function getTeamContext( - projectId: string, - epicId?: string -): Promise { +export async function getTeamContext(projectId: string, epicId?: string): Promise { const { data, error } = await api.GET('/projects/{projectId}/team-context', { params: { path: { projectId }, diff --git a/web/src/lib/components/CopyIdButton.svelte b/web/src/lib/components/CopyIdButton.svelte index 828e68f..847dd33 100644 --- a/web/src/lib/components/CopyIdButton.svelte +++ b/web/src/lib/components/CopyIdButton.svelte @@ -14,11 +14,7 @@ let timeoutId: ReturnType | undefined; const opacity = $derived( - copied ? 1 - : hovered ? 1 - : reveal === 'visible' ? 0.5 - : groupHovered ? 0.5 - : 0 + copied ? 1 : hovered ? 1 : reveal === 'visible' ? 0.5 : groupHovered ? 0.5 : 0 ); function copyToClipboard(text: string) { @@ -65,7 +61,9 @@ {:else} - + {/if} @@ -88,7 +86,9 @@ padding: 0; line-height: 1; position: relative; - transition: opacity 150ms ease, color 150ms ease; + transition: + opacity 150ms ease, + color 150ms ease; } .copy-id-btn:hover { diff --git a/web/src/lib/components/FilesystemBrowser.svelte b/web/src/lib/components/FilesystemBrowser.svelte index bd824f2..2b7323c 100644 --- a/web/src/lib/components/FilesystemBrowser.svelte +++ b/web/src/lib/components/FilesystemBrowser.svelte @@ -68,7 +68,13 @@ disabled={loading || !currentDir.trim()} > {#if loading} - + {:else} @@ -104,7 +110,9 @@ {#if error} -
+
{error}
{/if} @@ -124,17 +132,31 @@ onclick={() => navigateTo(entry.path)} > - - + + {entry.name} {#if entry.is_git_repo} - + git {/if} - + diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index 985207a..f3d7741 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -78,10 +78,7 @@
{#if project}