From a077609796939541559c1300494387b405274d39 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Sat, 28 Mar 2026 11:45:45 -0700 Subject: [PATCH 01/20] chore: adding eval workspace to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From cfcbce91e2bc1fc61384fd39986d5341f5a611a0 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 13:43:45 -0700 Subject: [PATCH 02/20] feat(paste): add foundation types, storage, and crypto helpers Establishes the shared contracts for the shared-review epic: Go types (PasteShare, PasteEvent, DTOs), Storage interface with sentinel errors, SQLite implementation with embed-based migrations, AES-256-GCM crypto helpers, and TypeScript mirrors with Vitest unit tests. --- internal/paste/crypto.go | 60 +++++++++ internal/paste/crypto_test.go | 42 ++++++ internal/paste/sqlite/migrations.go | 54 ++++++++ internal/paste/sqlite/migrations/001_init.sql | 20 +++ internal/paste/sqlite/store.go | 111 ++++++++++++++++ internal/paste/sqlite/store_test.go | 122 ++++++++++++++++++ internal/paste/storage.go | 19 +++ internal/paste/types.go | 46 +++++++ internal/paste/types_test.go | 32 +++++ web/bun.lock | 49 +++++++ web/package.json | 5 +- web/src/lib/paste/crypto.test.ts | 42 ++++++ web/src/lib/paste/crypto.ts | 49 +++++++ web/src/lib/paste/types.ts | 85 ++++++++++++ web/vitest.config.ts | 7 + 15 files changed, 741 insertions(+), 2 deletions(-) create mode 100644 internal/paste/crypto.go create mode 100644 internal/paste/crypto_test.go create mode 100644 internal/paste/sqlite/migrations.go create mode 100644 internal/paste/sqlite/migrations/001_init.sql create mode 100644 internal/paste/sqlite/store.go create mode 100644 internal/paste/sqlite/store_test.go create mode 100644 internal/paste/storage.go create mode 100644 internal/paste/types.go create mode 100644 internal/paste/types_test.go create mode 100644 web/src/lib/paste/crypto.test.ts create mode 100644 web/src/lib/paste/crypto.ts create mode 100644 web/src/lib/paste/types.ts create mode 100644 web/vitest.config.ts diff --git a/internal/paste/crypto.go b/internal/paste/crypto.go new file mode 100644 index 0000000..d91e812 --- /dev/null +++ b/internal/paste/crypto.go @@ -0,0 +1,60 @@ +package paste + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "errors" +) + +const KeySize = 32 + +func GenerateKey() ([]byte, error) { + key := make([]byte, KeySize) + _, err := rand.Read(key) + return key, err +} + +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 +} + +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..fa1bb1f --- /dev/null +++ b/internal/paste/crypto_test.go @@ -0,0 +1,42 @@ +package paste + +import ( + "bytes" + "testing" +) + +func TestEncryptDecryptRoundtrip(t *testing.T) { + key, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + in := map[string]any{"hello": "world", "n": float64(42)} + ct, iv, err := EncryptJSON(in, key) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := 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, _ := GenerateKey() + k2, _ := GenerateKey() + ct, iv, _ := EncryptJSON("secret", k1) + var out string + if err := 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 := EncryptJSON("x", short); err == nil { + t.Error("expected error for short key") + } +} diff --git a/internal/paste/sqlite/migrations.go b/internal/paste/sqlite/migrations.go new file mode 100644 index 0000000..2c8c486 --- /dev/null +++ b/internal/paste/sqlite/migrations.go @@ -0,0 +1,54 @@ +package sqlite + +import ( + "context" + "database/sql" + "embed" + "fmt" + "io/fs" + "sort" + "strings" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +func Apply(ctx context.Context, db *sql.DB) error { + 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 + } + 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 { + var existing string + err := db.QueryRowContext(ctx, `SELECT name FROM paste_migrations WHERE name = ?`, name).Scan(&existing) + if err == nil { + continue + } + if 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) + } + 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..208860b --- /dev/null +++ b/internal/paste/sqlite/store.go @@ -0,0 +1,111 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/sentiolabs/arc/internal/paste" +) + +type Store struct { + db *sql.DB +} + +func New(db *sql.DB) *Store { return &Store{db: db} } + +func (s *Store) CreateShare(ctx context.Context, share paste.PasteShare, 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 +} + +func (s *Store) GetShare(ctx context.Context, id string) (*paste.PasteShare, error) { + var sh paste.PasteShare + 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 +} + +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 +} + +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 +} + +func (s *Store) AppendEvent(ctx context.Context, e paste.PasteEvent) 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 +} + +func (s *Store) ListEvents(ctx context.Context, shareID string) ([]paste.PasteEvent, 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.PasteEvent + for rows.Next() { + var e paste.PasteEvent + 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() +} + +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..f7feecd --- /dev/null +++ b/internal/paste/sqlite/store_test.go @@ -0,0 +1,122 @@ +package sqlite_test + +import ( + "context" + "database/sql" + "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.PasteShare{ + 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.PasteShare{ + 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.PasteShare{ + 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 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.PasteShare{ + ID: "x", + PlanBlob: []byte{0}, + PlanIV: []byte{0}, + SchemaVer: 1, + CreatedAt: now, + UpdatedAt: now, + }, "tok") + _ = s.AppendEvent(context.Background(), paste.PasteEvent{ + ID: "e1", + ShareID: "x", + Blob: []byte{1}, + IV: []byte{1}, + CreatedAt: now, + }) + _ = s.AppendEvent(context.Background(), paste.PasteEvent{ + 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..608ef13 --- /dev/null +++ b/internal/paste/storage.go @@ -0,0 +1,19 @@ +package paste + +import ( + "context" + "errors" +) + +var ErrShareNotFound = errors.New("paste share not found") +var ErrInvalidEditToken = errors.New("invalid edit token") + +type Storage interface { + CreateShare(ctx context.Context, s PasteShare, editToken string) error + GetShare(ctx context.Context, id string) (*PasteShare, 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 PasteEvent) error + ListEvents(ctx context.Context, shareID string) ([]PasteEvent, error) + VerifyEditToken(ctx context.Context, id, token string) (bool, error) +} diff --git a/internal/paste/types.go b/internal/paste/types.go new file mode 100644 index 0000000..76c7b0c --- /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 PasteShare 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 PasteEvent 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 { + PasteShare + Events []PasteEvent `json:"events"` +} diff --git a/internal/paste/types_test.go b/internal/paste/types_test.go new file mode 100644 index 0000000..5d26c09 --- /dev/null +++ b/internal/paste/types_test.go @@ -0,0 +1,32 @@ +package paste + +import ( + "testing" + "time" +) + +func TestPasteShareContract(t *testing.T) { + var s PasteShare + var _ string = s.ID + var _ []byte = s.PlanBlob + var _ []byte = s.PlanIV + var _ int = s.SchemaVer + var _ time.Time = s.CreatedAt + var _ time.Time = s.UpdatedAt + var _ *time.Time = s.ExpiresAt +} + +func TestPasteEventContract(t *testing.T) { + var e PasteEvent + var _ string = e.ID + var _ string = e.ShareID + var _ []byte = e.Blob + var _ []byte = e.IV + var _ time.Time = e.CreatedAt +} + +func TestCreatePasteResponseContract(t *testing.T) { + var r CreatePasteResponse + var _ string = r.ID + var _ string = r.EditToken +} diff --git a/web/bun.lock b/web/bun.lock index da2c14b..6f52750 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -35,6 +35,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.9.3", "vite": "^7.2.6", + "vitest": "^4.1.5", }, }, }, @@ -295,8 +296,12 @@ "@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 +340,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 +370,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 +386,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 +410,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 +446,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 +474,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 +658,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=="], @@ -685,12 +718,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 +748,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 +802,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 +814,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=="], diff --git a/web/package.json b/web/package.json index 86924a4..5fc9659 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" @@ -43,7 +43,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/src/lib/paste/crypto.test.ts b/web/src/lib/paste/crypto.test.ts new file mode 100644 index 0000000..5326ba9 --- /dev/null +++ b/web/src/lib/paste/crypto.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { + generateKey, + encryptJSON, + decryptJSON, + exportKey, + importKey, + base64UrlEncode, + base64UrlDecode, +} from './crypto'; + +describe('paste crypto', () => { + it('round-trips JSON', async () => { + const key = await generateKey(); + const value = { hello: 'world', n: 42 }; + const { blob, iv } = await encryptJSON(value, key); + const decoded = await decryptJSON(blob, iv, key); + expect(decoded).toEqual(value); + }); + + it('fails to decrypt with wrong key', async () => { + const k1 = await generateKey(); + const k2 = await generateKey(); + const { blob, iv } = await encryptJSON('secret', k1); + await expect(decryptJSON(blob, iv, k2)).rejects.toBeDefined(); + }); + + it('exports and re-imports key losslessly', async () => { + const k = await generateKey(); + const exported = await exportKey(k); + const imported = await importKey(exported); + const { blob, iv } = await encryptJSON('hi', k); + const out = await decryptJSON(blob, iv, imported); + expect(out).toBe('hi'); + }); + + it('base64url round-trips arbitrary bytes', () => { + const bytes = new Uint8Array([0, 255, 1, 254, 100]); + const decoded = base64UrlDecode(base64UrlEncode(bytes)); + expect([...decoded]).toEqual([...bytes]); + }); +}); diff --git a/web/src/lib/paste/crypto.ts b/web/src/lib/paste/crypto.ts new file mode 100644 index 0000000..6135e45 --- /dev/null +++ b/web/src/lib/paste/crypto.ts @@ -0,0 +1,49 @@ +const ALGO = 'AES-GCM'; +const KEY_LEN_BITS = 256; +const IV_LEN_BYTES = 12; + +export async function generateKey(): Promise { + return crypto.subtle.generateKey({ name: ALGO, length: KEY_LEN_BITS }, true, [ + 'encrypt', + 'decrypt', + ]); +} + +export async function exportKey(key: CryptoKey): Promise { + const raw = await crypto.subtle.exportKey('raw', key); + return base64UrlEncode(new Uint8Array(raw)); +} + +export async function importKey(b64url: string): Promise { + const raw = base64UrlDecode(b64url); + return crypto.subtle.importKey('raw', raw, { name: ALGO }, true, ['encrypt', 'decrypt']); +} + +export async function encryptJSON( + value: T, + key: CryptoKey, +): Promise<{ blob: Uint8Array; iv: Uint8Array }> { + const iv = crypto.getRandomValues(new Uint8Array(IV_LEN_BYTES)); + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const ct = await crypto.subtle.encrypt({ name: ALGO, iv }, key, plaintext); + return { blob: new Uint8Array(ct), iv }; +} + +export async function decryptJSON(blob: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise { + const plain = await crypto.subtle.decrypt({ name: ALGO, iv }, key, blob); + return JSON.parse(new TextDecoder().decode(plain)) as T; +} + +export function base64UrlEncode(bytes: Uint8Array): string { + let s = btoa(String.fromCharCode(...bytes)); + return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function base64UrlDecode(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); + const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad; + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/web/src/lib/paste/types.ts b/web/src/lib/paste/types.ts new file mode 100644 index 0000000..55ca90c --- /dev/null +++ b/web/src/lib/paste/types.ts @@ -0,0 +1,85 @@ +export type PasteShareResponse = { + id: string; + plan_blob: string; + plan_iv: string; + schema_ver: number; + created_at: string; + updated_at: string; + expires_at?: string; +}; + +export type PasteEventResponse = { + id: string; + share_id: string; + blob: string; + iv: string; + created_at: string; +}; + +export type GetPasteResponse = PasteShareResponse & { events: PasteEventResponse[] }; + +export type CreatePasteRequest = { + plan_blob: string; + plan_iv: string; + schema_ver: number; + expires_at?: string; +}; + +export type CreatePasteResponse = { id: string; edit_token: string }; + +export type AppendEventRequest = { blob: string; iv: string }; + +export type PlanPlaintext = { + version: 1; + markdown: string; + title?: string; + author_name?: string; + created_at: string; +}; + +export type CommentType = 'comment' | 'praise' | 'issue' | 'suggestion' | 'question' | 'nit'; +export type Severity = 'important' | 'nit'; +export type ResolutionStatus = 'accepted' | 'rejected' | 'resolved' | 'reopened'; + +export type Anchor = { + line_start: number; + line_end: number; + char_start?: number; + char_end?: number; + quoted_text: string; + context_before?: string; + context_after?: string; + heading_slug?: string; +}; + +export type CommentEvent = { + kind: 'comment'; + id: string; + author_name: string; + comment_type: CommentType; + severity?: Severity; + body: string; + suggested_text?: string; + parent_id?: string; + anchor: Anchor; + created_at: string; +}; + +export type ResolutionEvent = { + kind: 'resolution'; + id: string; + comment_id: string; + status: ResolutionStatus; + reply?: string; + author_name: string; + created_at: string; +}; + +export type PlanEditEvent = { + kind: 'plan_edit'; + id: string; + edit_summary?: string; + created_at: string; +}; + +export type EventPlaintext = CommentEvent | ResolutionEvent | PlanEditEvent; diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..f5a2891 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); From 4d537276735ff109c9fdd98dec77b60b78ac6295 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 13:47:29 -0700 Subject: [PATCH 03/20] fix(paste): coerce Uint8Array to BufferSource for Web Crypto API --- web/src/lib/paste/crypto.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/web/src/lib/paste/crypto.ts b/web/src/lib/paste/crypto.ts index 6135e45..8b23e65 100644 --- a/web/src/lib/paste/crypto.ts +++ b/web/src/lib/paste/crypto.ts @@ -15,7 +15,9 @@ export async function exportKey(key: CryptoKey): Promise { } export async function importKey(b64url: string): Promise { - const raw = base64UrlDecode(b64url); + const decoded = base64UrlDecode(b64url); + // Copy to ensure backing buffer is ArrayBuffer, not SharedArrayBuffer + const raw = new Uint8Array(decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength)) as Uint8Array; return crypto.subtle.importKey('raw', raw, { name: ALGO }, true, ['encrypt', 'decrypt']); } @@ -25,12 +27,22 @@ export async function encryptJSON( ): Promise<{ blob: Uint8Array; iv: Uint8Array }> { const iv = crypto.getRandomValues(new Uint8Array(IV_LEN_BYTES)); const plaintext = new TextEncoder().encode(JSON.stringify(value)); - const ct = await crypto.subtle.encrypt({ name: ALGO, iv }, key, plaintext); + // Copy to ensure backing buffer is ArrayBuffer, not SharedArrayBuffer + const plaintextAsArrayBuffer = plaintext as unknown as Uint8Array; + const plaintextCopy = new Uint8Array(plaintextAsArrayBuffer.buffer.slice(plaintextAsArrayBuffer.byteOffset, plaintextAsArrayBuffer.byteOffset + plaintextAsArrayBuffer.byteLength)) as Uint8Array; + const ct = await crypto.subtle.encrypt({ name: ALGO, iv }, key, plaintextCopy); return { blob: new Uint8Array(ct), iv }; } export async function decryptJSON(blob: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise { - const plain = await crypto.subtle.decrypt({ name: ALGO, iv }, key, blob); + // Copy to ensure backing buffer is ArrayBuffer, not SharedArrayBuffer + const blobAsArrayBuffer = blob as unknown as Uint8Array; + const ivAsArrayBuffer = iv as unknown as Uint8Array; + const plain = await crypto.subtle.decrypt( + { name: ALGO, iv: ivAsArrayBuffer }, + key, + new Uint8Array((blobAsArrayBuffer as Uint8Array).buffer.slice((blobAsArrayBuffer as Uint8Array).byteOffset, (blobAsArrayBuffer as Uint8Array).byteOffset + (blobAsArrayBuffer as Uint8Array).byteLength)) as any, + ); return JSON.parse(new TextDecoder().decode(plain)) as T; } From 1dabc299ecbe3c3e128c4819206b1eb5e95ba7f4 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 13:50:33 -0700 Subject: [PATCH 04/20] feat(paste): add HTTP handlers for /api/paste/* Implements five endpoints (POST, GET, PUT, DELETE, POST blobs) with Bearer token auth, proper HTTP status codes, and 10 test cases covering happy paths, auth errors, and missing resource cases. --- internal/paste/handlers.go | 203 ++++++++++++++++++++++++++ internal/paste/handlers_test.go | 249 ++++++++++++++++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 internal/paste/handlers.go create mode 100644 internal/paste/handlers_test.go diff --git a/internal/paste/handlers.go b/internal/paste/handlers.go new file mode 100644 index 0000000..4d335cb --- /dev/null +++ b/internal/paste/handlers.go @@ -0,0 +1,203 @@ +package paste + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +// 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) +} + +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 := PasteShare{ + 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}) +} + +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 = []PasteEvent{} + } + return c.JSON(http.StatusOK, GetPasteResponse{PasteShare: *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 := PasteEvent{ + 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 an 8-character Crockford base32 (lowercase, without i/l/o/u) ID. +func newShareID() (string, error) { + const alphabet = "0123456789abcdefghjkmnpqrstvwxyz" + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", err + } + out := make([]byte, 8) + for i := range out { + out[i] = alphabet[int(buf[i])%len(alphabet)] + } + return string(out), nil +} + +// newEditToken returns a 64-character hex-encoded random token (32 random bytes). +func newEditToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +// newEventID returns a time-prefixed random hex event ID. +func newEventID() (string, error) { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + return "", err + } + ts := time.Now().UTC().UnixNano() + return hex.EncodeToString([]byte{ + byte(ts >> 56), byte(ts >> 48), byte(ts >> 40), byte(ts >> 32), + byte(ts >> 24), byte(ts >> 16), byte(ts >> 8), 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..1afc8a4 --- /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) + } + if got.Events == nil { + // Events may be nil when empty slice; just ensure no panic + } +} + +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]interface{}{"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]interface{}{"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]interface{}{"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()) + } +} From 28b75e50ff13a1b2e9b0793a3d6ed7386a7a480c Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 13:51:10 -0700 Subject: [PATCH 05/20] feat(web): add paste client, anchor, events, identity utilities Implement four utility modules for the paste/shared-review feature: client.ts (fetch wrappers), anchor.ts (4-step resilience resolution), events.ts (CRDT-lite replay), and identity.ts (localStorage name capture). All modules are fully tested with 16 passing Vitest unit tests. --- web/src/lib/paste/anchor.test.ts | 33 +++++++++++++ web/src/lib/paste/anchor.ts | 79 ++++++++++++++++++++++++++++++ web/src/lib/paste/client.test.ts | 32 ++++++++++++ web/src/lib/paste/client.ts | 63 ++++++++++++++++++++++++ web/src/lib/paste/events.test.ts | 49 ++++++++++++++++++ web/src/lib/paste/events.ts | 36 ++++++++++++++ web/src/lib/paste/identity.test.ts | 23 +++++++++ web/src/lib/paste/identity.ts | 16 ++++++ 8 files changed, 331 insertions(+) create mode 100644 web/src/lib/paste/anchor.test.ts create mode 100644 web/src/lib/paste/anchor.ts create mode 100644 web/src/lib/paste/client.test.ts create mode 100644 web/src/lib/paste/client.ts create mode 100644 web/src/lib/paste/events.test.ts create mode 100644 web/src/lib/paste/events.ts create mode 100644 web/src/lib/paste/identity.test.ts create mode 100644 web/src/lib/paste/identity.ts diff --git a/web/src/lib/paste/anchor.test.ts b/web/src/lib/paste/anchor.test.ts new file mode 100644 index 0000000..f7b5a41 --- /dev/null +++ b/web/src/lib/paste/anchor.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { resolveAnchor } from './anchor'; + +describe('resolveAnchor', () => { + const plan = '# Title\n\nFirst paragraph.\nSecond paragraph.\n## Sub\nThird.\n'; + + it('returns ok when quoted_text still at original lines', () => { + const r = resolveAnchor(plan, { line_start: 3, line_end: 3, quoted_text: 'First paragraph.' }); + expect(r.status).toBe('ok'); + }); + + it('returns drifted when found via heading slug', () => { + const edited = 'PRELUDE\n# Title\n\nMore content.\nFirst paragraph.\n## Sub\nThird.\n'; + const r = resolveAnchor(edited, { + line_start: 3, + line_end: 3, + quoted_text: 'First paragraph.', + heading_slug: 'title', + }); + expect(r.status).toBe('drifted'); + }); + + it('returns orphaned when text is gone', () => { + const edited = '# Title\n\nDifferent stuff.\n'; + const r = resolveAnchor(edited, { + line_start: 3, + line_end: 3, + quoted_text: 'First paragraph.', + heading_slug: 'title', + }); + expect(r.status).toBe('orphaned'); + }); +}); diff --git a/web/src/lib/paste/anchor.ts b/web/src/lib/paste/anchor.ts new file mode 100644 index 0000000..a0ba6ac --- /dev/null +++ b/web/src/lib/paste/anchor.ts @@ -0,0 +1,79 @@ +import type { Anchor } from './types'; + +export type AnchorResolution = { + line_start: number; + line_end: number; + char_start?: number; + char_end?: number; + status: 'ok' | 'drifted' | 'orphaned'; +}; + +export function resolveAnchor(plan: string, anchor: Anchor): AnchorResolution { + const lines = plan.split('\n'); + + if (anchor.line_start <= lines.length && anchor.line_end <= lines.length) { + const slice = lines.slice(anchor.line_start - 1, anchor.line_end).join('\n'); + if (slice.includes(anchor.quoted_text)) { + return { + line_start: anchor.line_start, + line_end: anchor.line_end, + char_start: anchor.char_start, + char_end: anchor.char_end, + status: 'ok', + }; + } + } + + if (anchor.heading_slug) { + const headingIdx = findHeadingIndex(lines, anchor.heading_slug); + if (headingIdx >= 0) { + const window = lines.slice(headingIdx, Math.min(headingIdx + 50, lines.length)).join('\n'); + const offset = window.indexOf(anchor.quoted_text); + if (offset >= 0) { + const lineNum = headingIdx + 1 + countNewlinesBefore(window, offset); + return { + line_start: lineNum, + line_end: lineNum + countNewlinesBefore(anchor.quoted_text, anchor.quoted_text.length), + status: 'drifted', + }; + } + } + } + + if (anchor.context_before && anchor.context_after) { + const needle = anchor.context_before + anchor.quoted_text + anchor.context_after; + const idx = plan.indexOf(needle); + if (idx >= 0) { + const lineNum = countNewlinesBefore(plan, idx + (anchor.context_before?.length ?? 0)) + 1; + return { + line_start: lineNum, + line_end: lineNum + countNewlinesBefore(anchor.quoted_text, anchor.quoted_text.length), + status: 'drifted', + }; + } + } + + return { line_start: anchor.line_start, line_end: anchor.line_end, status: 'orphaned' }; +} + +function findHeadingIndex(lines: string[], slug: string): number { + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^#+\s+(.*)$/); + if (m && slugify(m[1]) === slug) return i; + } + return -1; +} + +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-'); +} + +function countNewlinesBefore(s: string, idx: number): number { + let n = 0; + for (let i = 0; i < idx && i < s.length; i++) if (s[i] === '\n') n++; + return n; +} diff --git a/web/src/lib/paste/client.test.ts b/web/src/lib/paste/client.test.ts new file mode 100644 index 0000000..7cf9d4b --- /dev/null +++ b/web/src/lib/paste/client.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PasteClient } from './client'; + +describe('PasteClient', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('POSTs base64-encoded blob+iv on create', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ id: 'abc', edit_token: 'tok' }), { status: 201 }), + ); + const c = new PasteClient('http://x'); + const res = await c.create(new Uint8Array([1, 2]), new Uint8Array([3, 4])); + expect(res.id).toBe('abc'); + expect(fetchMock).toHaveBeenCalledWith( + 'http://x/api/paste', + expect.objectContaining({ method: 'POST' }), + ); + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.schema_ver).toBe(1); + expect(body.plan_blob).toMatch(/^[A-Za-z0-9+/=]+$/); + }); + + it('sends Bearer token on update', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 204 }), + ); + const c = new PasteClient('http://x'); + await c.updatePlan('abc', 'mytoken', new Uint8Array([1]), new Uint8Array([2])); + const opts = fetchMock.mock.calls[0][1] as RequestInit; + expect((opts.headers as Record).Authorization).toBe('Bearer mytoken'); + }); +}); diff --git a/web/src/lib/paste/client.ts b/web/src/lib/paste/client.ts new file mode 100644 index 0000000..82cf47c --- /dev/null +++ b/web/src/lib/paste/client.ts @@ -0,0 +1,63 @@ +import type { + CreatePasteResponse, + GetPasteResponse, + PasteEventResponse, +} from './types'; + +export class PasteClient { + constructor(private baseUrl: string) {} + + async create(planBlob: Uint8Array, planIv: Uint8Array, schemaVer = 1): Promise { + const res = await fetch(`${this.baseUrl}/api/paste`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + plan_blob: bytesToB64(planBlob), + plan_iv: bytesToB64(planIv), + schema_ver: schemaVer, + }), + }); + if (!res.ok) throw new Error(`create paste failed: ${res.status}`); + return await res.json(); + } + + async get(id: string): Promise { + const res = await fetch(`${this.baseUrl}/api/paste/${id}`); + if (!res.ok) throw new Error(`get paste failed: ${res.status}`); + return await res.json(); + } + + async appendEvent(id: string, blob: Uint8Array, iv: Uint8Array): Promise<{ id: string }> { + const res = await fetch(`${this.baseUrl}/api/paste/${id}/blobs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blob: bytesToB64(blob), iv: bytesToB64(iv) }), + }); + if (!res.ok) throw new Error(`append event failed: ${res.status}`); + return await res.json(); + } + + async updatePlan(id: string, editToken: string, planBlob: Uint8Array, planIv: Uint8Array): Promise { + const res = await fetch(`${this.baseUrl}/api/paste/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${editToken}` }, + body: JSON.stringify({ plan_blob: bytesToB64(planBlob), plan_iv: bytesToB64(planIv) }), + }); + if (!res.ok) throw new Error(`update plan failed: ${res.status}`); + } +} + +function bytesToB64(b: Uint8Array): string { + return btoa(String.fromCharCode(...b)); +} + +export function b64ToBytes(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +export function eventBytes(e: PasteEventResponse): { blob: Uint8Array; iv: Uint8Array } { + return { blob: b64ToBytes(e.blob), iv: b64ToBytes(e.iv) }; +} diff --git a/web/src/lib/paste/events.test.ts b/web/src/lib/paste/events.test.ts new file mode 100644 index 0000000..7600d5c --- /dev/null +++ b/web/src/lib/paste/events.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { replayEvents, acceptedOnly } from './events'; +import type { CommentEvent, ResolutionEvent } from './types'; + +const c: CommentEvent = { + kind: 'comment', + id: 'c1', + author_name: 'Alice', + comment_type: 'comment', + body: 'looks good', + anchor: { line_start: 1, line_end: 1, quoted_text: 'x' }, + created_at: '2026-04-29T00:00:00Z', +}; +const accept: ResolutionEvent = { + kind: 'resolution', + id: 'r1', + comment_id: 'c1', + status: 'accepted', + author_name: 'Ben', + created_at: '2026-04-29T00:01:00Z', +}; + +describe('replayEvents', () => { + it('marks comment accepted when resolver is plan author', () => { + const states = replayEvents('Ben', [c, accept]); + expect(states.get('c1')?.status).toBe('accepted'); + }); + + it('ignores resolution from non-author', () => { + const states = replayEvents('Ben', [c, { ...accept, author_name: 'Mallory' }]); + expect(states.get('c1')?.status).toBe('open'); + }); + + it('acceptedOnly filters non-accepted', () => { + const states = replayEvents('Ben', [c, accept]); + expect(acceptedOnly(states).length).toBe(1); + }); + + it('replays in created_at order', () => { + const reject: ResolutionEvent = { + ...accept, + id: 'r2', + status: 'rejected', + created_at: '2026-04-29T00:02:00Z', + }; + const states = replayEvents('Ben', [c, accept, reject]); + expect(states.get('c1')?.status).toBe('rejected'); + }); +}); diff --git a/web/src/lib/paste/events.ts b/web/src/lib/paste/events.ts new file mode 100644 index 0000000..7b0db48 --- /dev/null +++ b/web/src/lib/paste/events.ts @@ -0,0 +1,36 @@ +import type { CommentEvent, EventPlaintext, ResolutionStatus } from './types'; + +export type CommentState = { + event: CommentEvent; + status: 'open' | ResolutionStatus; + reply?: string; + replyAt?: string; +}; + +export function replayEvents( + planAuthor: string | undefined, + events: EventPlaintext[], +): Map { + const sorted = [...events].sort((a, b) => { + const c = a.created_at.localeCompare(b.created_at); + return c !== 0 ? c : a.id.localeCompare(b.id); + }); + const states = new Map(); + for (const e of sorted) { + if (e.kind === 'comment') { + states.set(e.id, { event: e, status: 'open' }); + } else if (e.kind === 'resolution') { + const target = states.get(e.comment_id); + if (!target) continue; + if (planAuthor && e.author_name !== planAuthor) continue; + target.status = e.status; + target.reply = e.reply; + target.replyAt = e.created_at; + } + } + return states; +} + +export function acceptedOnly(states: Map): CommentState[] { + return [...states.values()].filter((s) => s.status === 'accepted'); +} diff --git a/web/src/lib/paste/identity.test.ts b/web/src/lib/paste/identity.test.ts new file mode 100644 index 0000000..7e5f83b --- /dev/null +++ b/web/src/lib/paste/identity.test.ts @@ -0,0 +1,23 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { getReviewerName, setReviewerName, clearReviewerName } from './identity'; + +describe('identity', () => { + beforeEach(() => localStorage.clear()); + + it('round-trips a name', () => { + setReviewerName('Alice'); + expect(getReviewerName()).toBe('Alice'); + }); + + it('clear removes the name', () => { + setReviewerName('Bob'); + clearReviewerName(); + expect(getReviewerName()).toBeNull(); + }); + + it('trims whitespace on set', () => { + setReviewerName(' Carol '); + expect(getReviewerName()).toBe('Carol'); + }); +}); diff --git a/web/src/lib/paste/identity.ts b/web/src/lib/paste/identity.ts new file mode 100644 index 0000000..420b672 --- /dev/null +++ b/web/src/lib/paste/identity.ts @@ -0,0 +1,16 @@ +const KEY = 'arc.reviewer.name'; + +export function getReviewerName(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem(KEY); +} + +export function setReviewerName(name: string): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(KEY, name.trim()); +} + +export function clearReviewerName(): void { + if (typeof localStorage === 'undefined') return; + localStorage.removeItem(KEY); +} From cc4923a3becf13e9c4f0381c5f8ef7f5ca048fdb Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 13:51:38 -0700 Subject: [PATCH 06/20] test(paste): add cross-language crypto roundtrip fixtures --- internal/paste/cmd/genxlang/main.go | 50 +++++++++++++ internal/paste/crypto_xlang_test.go | 83 +++++++++++++++++++++ internal/paste/testdata/README.md | 15 ++++ internal/paste/testdata/xlang_fixtures.json | 32 ++++++++ web/src/lib/paste/crypto.xlang.test.ts | 32 ++++++++ 5 files changed, 212 insertions(+) create mode 100644 internal/paste/cmd/genxlang/main.go create mode 100644 internal/paste/crypto_xlang_test.go create mode 100644 internal/paste/testdata/README.md create mode 100644 internal/paste/testdata/xlang_fixtures.json create mode 100644 web/src/lib/paste/crypto.xlang.test.ts diff --git a/internal/paste/cmd/genxlang/main.go b/internal/paste/cmd/genxlang/main.go new file mode 100644 index 0000000..d6f014d --- /dev/null +++ b/internal/paste/cmd/genxlang/main.go @@ -0,0 +1,50 @@ +// 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"}, + }}, + } + var out []fixture + 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)) + _ = os.WriteFile("internal/paste/testdata/xlang_fixtures.json", data, 0o644) +} diff --git a/internal/paste/crypto_xlang_test.go b/internal/paste/crypto_xlang_test.go new file mode 100644 index 0000000..ebf85f1 --- /dev/null +++ b/internal/paste/crypto_xlang_test.go @@ -0,0 +1,83 @@ +package paste + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +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 := 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 := EncryptJSON(json.RawMessage(f.Plaintext), key) + if err != nil { + t.Fatal(err) + } + var out json.RawMessage + if err := 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/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/web/src/lib/paste/crypto.xlang.test.ts b/web/src/lib/paste/crypto.xlang.test.ts new file mode 100644 index 0000000..fae9c7c --- /dev/null +++ b/web/src/lib/paste/crypto.xlang.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { importKey, decryptJSON } from './crypto'; + +const FIXTURES_PATH = join(__dirname, '../../../../internal/paste/testdata/xlang_fixtures.json'); +const fixtures: Array<{ + name: string; + key_b64url: string; + plaintext: unknown; + ciphertext_b64: string; + iv_b64: string; +}> = JSON.parse(readFileSync(FIXTURES_PATH, 'utf8')); + +describe('crypto xlang', () => { + for (const f of fixtures) { + it(`decrypts Go-produced fixture: ${f.name}`, async () => { + const key = await importKey(f.key_b64url); + const blob = stdB64Decode(f.ciphertext_b64); + const iv = stdB64Decode(f.iv_b64); + const got = await decryptJSON(blob, iv, key); + expect(got).toEqual(f.plaintext); + }); + } +}); + +function stdB64Decode(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} From 9a936ac2a79b5384eed1b5f279c9b4b1601adf68 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 13:57:35 -0700 Subject: [PATCH 07/20] feat(web): add /share/[id] route with full annotation markup Implements the SvelteKit route web/src/routes/share/[id]/ with all 11 components for the encrypted plan review UI: PlanRenderer (markdown with source-line anchors), AnnotationToolbar, LabelPicker, CommentCard, CommentThread, ResolveControls, SuggestionDiff, NamePromptModal, and DriftBadge. The route decrypts pastes via window.location.hash key, replays comment events, and gates submission on a reviewer name prompt. --- web/src/routes/share/[id]/+page.svelte | 142 ++++++++++++++++++ web/src/routes/share/[id]/+page.ts | 7 + .../[id]/components/AnnotationToolbar.svelte | 63 ++++++++ .../share/[id]/components/CommentCard.svelte | 34 +++++ .../[id]/components/CommentThread.svelte | 21 +++ .../share/[id]/components/DriftBadge.svelte | 9 ++ .../share/[id]/components/LabelPicker.svelte | 26 ++++ .../[id]/components/NamePromptModal.svelte | 28 ++++ .../share/[id]/components/PlanRenderer.svelte | 75 +++++++++ .../[id]/components/ResolveControls.svelte | 42 ++++++ .../[id]/components/SuggestionDiff.svelte | 10 ++ 11 files changed, 457 insertions(+) create mode 100644 web/src/routes/share/[id]/+page.svelte create mode 100644 web/src/routes/share/[id]/+page.ts create mode 100644 web/src/routes/share/[id]/components/AnnotationToolbar.svelte create mode 100644 web/src/routes/share/[id]/components/CommentCard.svelte create mode 100644 web/src/routes/share/[id]/components/CommentThread.svelte create mode 100644 web/src/routes/share/[id]/components/DriftBadge.svelte create mode 100644 web/src/routes/share/[id]/components/LabelPicker.svelte create mode 100644 web/src/routes/share/[id]/components/NamePromptModal.svelte create mode 100644 web/src/routes/share/[id]/components/PlanRenderer.svelte create mode 100644 web/src/routes/share/[id]/components/ResolveControls.svelte create mode 100644 web/src/routes/share/[id]/components/SuggestionDiff.svelte diff --git a/web/src/routes/share/[id]/+page.svelte b/web/src/routes/share/[id]/+page.svelte new file mode 100644 index 0000000..e452848 --- /dev/null +++ b/web/src/routes/share/[id]/+page.svelte @@ -0,0 +1,142 @@ + + +{#if !plan} +

Loading…

+{:else} +
+ { + selection = a; + }} + /> + +
+{/if} + +{#if showNamePrompt} + { + reviewerName = name; + showNamePrompt = false; + if (pendingComment) handleSubmitComment(pendingComment); + }} + /> +{/if} diff --git a/web/src/routes/share/[id]/+page.ts b/web/src/routes/share/[id]/+page.ts new file mode 100644 index 0000000..60375fa --- /dev/null +++ b/web/src/routes/share/[id]/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const ssr = false; // SPA-only; we need window.location.hash and crypto.subtle + +export const load: PageLoad = async ({ params }) => { + return { id: params.id }; +}; diff --git a/web/src/routes/share/[id]/components/AnnotationToolbar.svelte b/web/src/routes/share/[id]/components/AnnotationToolbar.svelte new file mode 100644 index 0000000..dd593e7 --- /dev/null +++ b/web/src/routes/share/[id]/components/AnnotationToolbar.svelte @@ -0,0 +1,63 @@ + + +
+ + + {#if commentType === 'issue'} + + {/if} + + {#if showSuggested} + + {/if} + +
diff --git a/web/src/routes/share/[id]/components/CommentCard.svelte b/web/src/routes/share/[id]/components/CommentCard.svelte new file mode 100644 index 0000000..0c504a6 --- /dev/null +++ b/web/src/routes/share/[id]/components/CommentCard.svelte @@ -0,0 +1,34 @@ + + +
  • +
    + {e.author_name} + {e.comment_type}{e.severity ? ` · ${e.severity}` : ''} +
    +

    {e.body}

    + {#if e.suggested_text} + + {/if} + +
    + {state.status} + {#if isAuthor} + { + /* parent wires up */ + }} + /> + {/if} +
    +
  • diff --git a/web/src/routes/share/[id]/components/CommentThread.svelte b/web/src/routes/share/[id]/components/CommentThread.svelte new file mode 100644 index 0000000..4a0f6cc --- /dev/null +++ b/web/src/routes/share/[id]/components/CommentThread.svelte @@ -0,0 +1,21 @@ + + +
    + + {#if replies.length} +
      + {#each replies as r (r.event.id)} + + {/each} +
    + {/if} +
    diff --git a/web/src/routes/share/[id]/components/DriftBadge.svelte b/web/src/routes/share/[id]/components/DriftBadge.svelte new file mode 100644 index 0000000..5eca9fa --- /dev/null +++ b/web/src/routes/share/[id]/components/DriftBadge.svelte @@ -0,0 +1,9 @@ + + +{#if status === 'drifted'} + anchor moved +{:else if status === 'orphaned'} + content removed +{/if} diff --git a/web/src/routes/share/[id]/components/LabelPicker.svelte b/web/src/routes/share/[id]/components/LabelPicker.svelte new file mode 100644 index 0000000..6bc97fa --- /dev/null +++ b/web/src/routes/share/[id]/components/LabelPicker.svelte @@ -0,0 +1,26 @@ + + +
    + {#each labels as l} + + {/each} +
    diff --git a/web/src/routes/share/[id]/components/NamePromptModal.svelte b/web/src/routes/share/[id]/components/NamePromptModal.svelte new file mode 100644 index 0000000..efb81b0 --- /dev/null +++ b/web/src/routes/share/[id]/components/NamePromptModal.svelte @@ -0,0 +1,28 @@ + + + diff --git a/web/src/routes/share/[id]/components/PlanRenderer.svelte b/web/src/routes/share/[id]/components/PlanRenderer.svelte new file mode 100644 index 0000000..e053bb5 --- /dev/null +++ b/web/src/routes/share/[id]/components/PlanRenderer.svelte @@ -0,0 +1,75 @@ + + + +
    + {@html html} +
    diff --git a/web/src/routes/share/[id]/components/ResolveControls.svelte b/web/src/routes/share/[id]/components/ResolveControls.svelte new file mode 100644 index 0000000..0642c41 --- /dev/null +++ b/web/src/routes/share/[id]/components/ResolveControls.svelte @@ -0,0 +1,42 @@ + + + + {#if currentStatus !== 'accepted'} + + {/if} + {#if currentStatus !== 'resolved'} + + {/if} + {#if currentStatus !== 'rejected'} + + {/if} + {#if currentStatus === 'resolved' || currentStatus === 'rejected' || currentStatus === 'accepted'} + + {/if} + + +{#if showRejectReply} +
    + + +
    +{/if} diff --git a/web/src/routes/share/[id]/components/SuggestionDiff.svelte b/web/src/routes/share/[id]/components/SuggestionDiff.svelte new file mode 100644 index 0000000..3329625 --- /dev/null +++ b/web/src/routes/share/[id]/components/SuggestionDiff.svelte @@ -0,0 +1,10 @@ + + +
    + {#if original} +
    {original}
    + {/if} +
    {suggested}
    +
    From 85cd69d7ad1b8ae2328b1be509d029bcf9d16ca1 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 13:58:58 -0700 Subject: [PATCH 08/20] feat(api): mount paste handlers in arc-server Wire the paste package into arc-server so that /api/paste/* endpoints are available at startup. Paste migrations run automatically on the same SQLite DB as arc's main storage. The SPA fallback already handles /share/[id] via the existing embed_webui.go handler. - Add pastesqlite.Apply call in initSchema after arc migrations succeed - Add DB() *sql.DB accessor to sqlite.Store for use by route registration - Create paste_routes.go with registerPasteRoutes(e, db) helper - Pass DB to api.Config in server.go (conditional: no-op when nil) - Wire store.DB() into api.Config in internal/server/server.go - Add TestPasteRoutesMounted and TestShareRouteFallsBackToSPA tests --- internal/api/paste_routes.go | 18 +++++++ internal/api/paste_routes_test.go | 87 +++++++++++++++++++++++++++++++ internal/api/server.go | 9 ++++ internal/server/server.go | 1 + internal/storage/sqlite/store.go | 15 +++++- 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 internal/api/paste_routes.go create mode 100644 internal/api/paste_routes_test.go 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(" Date: Wed, 29 Apr 2026 13:56:59 -0700 Subject: [PATCH 09/20] feat(arc-paste): add standalone paste service binary --- Makefile | 5 ++++ arc-paste/Dockerfile | 22 +++++++++++++++ arc-paste/README.md | 43 +++++++++++++++++++++++++++++ arc-paste/main.go | 61 ++++++++++++++++++++++++++++++++++++++++++ arc-paste/main_test.go | 37 +++++++++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 arc-paste/Dockerfile create mode 100644 arc-paste/README.md create mode 100644 arc-paste/main.go create mode 100644 arc-paste/main_test.go diff --git a/Makefile b/Makefile index df40361..c168639 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 + @echo "==> Building arc-paste binary..." + $(GO) build -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..be1f5f5 --- /dev/null +++ b/arc-paste/Dockerfile @@ -0,0 +1,22 @@ +# Stage 1: Build the web UI +FROM node:20-alpine AS web +WORKDIR /web +COPY web/package.json web/bun.lock* ./ +RUN npm install +COPY web . +RUN npm run build + +# Stage 2: Build the Go binary +FROM golang:1.23-alpine AS go-build +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=web /web/build ./web/build +RUN CGO_ENABLED=0 go build -o /out/arc-paste ./arc-paste + +# Stage 3: Runtime container +FROM gcr.io/distroless/static-debian12 +COPY --from=go-build /out/arc-paste /arc-paste +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/main.go b/arc-paste/main.go new file mode 100644 index 0000000..4b27047 --- /dev/null +++ b/arc-paste/main.go @@ -0,0 +1,61 @@ +// 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" + "log" + "os" + + "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" +) + +func main() { + addr := envOr("ARC_PASTE_ADDR", ":7433") + dbPath := envOr("ARC_PASTE_DB", "./arc-paste.db") + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer db.Close() + + if err := pastesqlite.Apply(context.Background(), db); err != nil { + log.Fatalf("apply migrations: %v", err) + } + + store := pastesqlite.New(db) + handlers := paste.NewHandlers(store) + + e := echo.New() + e.HideBanner = true + e.Use(middleware.Logger()) + 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) + + if err := e.Start(addr); err != nil { + log.Fatalf("start server: %v", err) + } +} + +// 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..7fb9c49 --- /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" + + _ "modernc.org/sqlite" + "github.com/labstack/echo/v4" + "github.com/sentiolabs/arc/internal/paste" + pastesqlite "github.com/sentiolabs/arc/internal/paste/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) + } +} From 69edd9f7b12867b7656ca5d48f9386da89065de9 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 14:03:14 -0700 Subject: [PATCH 10/20] feat(cli): add arc share commands and shares.json registry Implements `arc share create|list|show|comments|pull|approve|update|delete` with client-side AES-GCM encryption via the paste package and persists share metadata (key, edit_token) to ~/.arc/shares.json (mode 0600). --- cmd/arc/share.go | 508 +++++++++++++++++++++ cmd/arc/share_test.go | 160 +++++++ internal/sharesconfig/sharesconfig.go | 122 +++++ internal/sharesconfig/sharesconfig_test.go | 46 ++ 4 files changed, 836 insertions(+) create mode 100644 cmd/arc/share.go create mode 100644 cmd/arc/share_test.go create mode 100644 internal/sharesconfig/sharesconfig.go create mode 100644 internal/sharesconfig/sharesconfig_test.go diff --git a/cmd/arc/share.go b/cmd/arc/share.go new file mode 100644 index 0000000..e224242 --- /dev/null +++ b/cmd/arc/share.go @@ -0,0 +1,508 @@ +// 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" + "fmt" + "io" + "net/http" + "net/url" + "os" + "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"` + 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"` +} + +// --- 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)", + Args: cobra.ExactArgs(2), + RunE: runShareUpdate, +} + +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 + 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", "", "Override server URL") + 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 + } + 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 + } + 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 --- + +// resolveServer returns the server URL and kind ("local" or "shared") based on +// the provided flags. +func resolveServer(local, share bool, override string) (string, string) { + if override != "" { + return override, "shared" + } + if share { + if env := os.Getenv("ARC_SHARE_SERVER"); env != "" { + return env, "shared" + } + return "https://share.arc.tools", "shared" + } + return cliConfigServerURL(), "local" +} + +// 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}) + 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}) + 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.PasteEvent, error) { + resp, err := http.Get(strings.TrimRight(server, "/") + "/api/paste/" + url.PathEscape(id)) + 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.PasteShare + Events []paste.PasteEvent `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. +func printComments(server, id string, key []byte, acceptedOnly, asJSON bool) error { + plan, events, err := fetchAndDecrypt(server, id, key) + if err != nil { + return err + } + comments := map[string]commentEvent{} + resolutions := map[string]resolutionEvent{} + 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"` + } + _ = json.Unmarshal(raw, &generic) + switch generic.Kind { + case "comment": + var c commentEvent + if err := json.Unmarshal(raw, &c); err == nil { + comments[c.ID] = c + } + case "resolution": + var r resolutionEvent + if err := json.Unmarshal(raw, &r); err == nil { + if plan.AuthorName == "" || r.AuthorName == plan.AuthorName { + resolutions[r.CommentID] = r + } + } + } + } + for cid, c := range comments { + res, ok := resolutions[cid] + status := "open" + if ok { + status = res.Status + } + if acceptedOnly && status != "accepted" { + continue + } + if asJSON { + payload := map[string]any{"comment": c, "status": status} + if ok && res.Reply != "" { + payload["reply"] = res.Reply + } + out, _ := json.Marshal(payload) + fmt.Println(string(out)) + } else { + fmt.Printf("[%s] %s (%s): %s\n", status, c.AuthorName, c.CommentType, c.Body) + } + } + return nil +} + +// resolveShareRef parses a share reference which may be either a full share +// URL (e.g. https://share.arc.tools/share/abc12345#k=KEY) or a bare share ID +// known to ~/.arc/shares.json. +func resolveShareRef(ref string) (string, string, []byte, error) { + if strings.Contains(ref, "://") { + u, err := url.Parse(ref) + if err != nil { + return "", "", nil, err + } + 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, _ := sharesconfig.Find(id); s != nil { + keyB64 = s.KeyB64Url + } + } + key, err := base64.RawURLEncoding.DecodeString(keyB64) + if err != nil { + return "", "", nil, err + } + return id, u.Scheme + "://" + u.Host, key, nil + } + s, err := sharesconfig.Find(ref) + if err != nil { + return "", "", nil, err + } + if s == nil { + return "", "", nil, fmt.Errorf("unknown share id: %s", ref) + } + key, _ := base64.RawURLEncoding.DecodeString(s.KeyB64Url) + return s.ID, s.URL, key, nil +} diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go new file mode 100644 index 0000000..06094ca --- /dev/null +++ b/cmd/arc/share_test.go @@ -0,0 +1,160 @@ +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."), 0o644) + + 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://share.arc.tools/share/abc12345#k=AAAA") + if err != nil { + t.Fatal(err) + } + if id != "abc12345" || server != "https://share.arc.tools" || 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"), 0o644) + 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) + } +} + +// 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() + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + 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() +} + +// contains is a convenience wrapper around strings.Contains. +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/internal/sharesconfig/sharesconfig.go b/internal/sharesconfig/sharesconfig.go new file mode 100644 index 0000000..e7a7ac4 --- /dev/null +++ b/internal/sharesconfig/sharesconfig.go @@ -0,0 +1,122 @@ +// 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" +) + +// 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), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// 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 nil if not found. +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, nil +} + +// 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..5cfae4e --- /dev/null +++ b/internal/sharesconfig/sharesconfig_test.go @@ -0,0 +1,46 @@ +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) + } +} From aca67c5e91d29dc041a95af796f97ed0c31ce6e6 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Wed, 29 Apr 2026 16:56:41 -0700 Subject: [PATCH 11/20] feat(web): rebuild /share/[id] with editorial review UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the initial review surface with an Editorial Marginalia aesthetic (dark paper, Newsreader serif body, ink-color metaphor) and a plannotator-style floating toolbar. The selection-driven workflow now offers six distinct intents (praise, comment, delete, suggest, quick-label, dismiss) instead of the original inline AnnotationToolbar. - Adds scoped --ink-* design tokens under .share-page in app.css - Skips arc's sidebar + projects fetch on /share/* via isShareRoute guard - Wraps quoted_text inline with via DOM walk (inline-annotations.ts) - Splits the right rail into AnnotationsPanel + AnnotationCard - Pulls comment/suggest into a single CommentPopover - Adds QuickLabelPicker for ⚡ menu with 1..5 keyboard shortcuts - Renders floating overlays inside .share-page so they inherit ink tokens - Adds action: 'comment' | 'delete' field to CommentEvent - Adds @tailwindcss/typography devDep Removes superseded components: AnnotationToolbar, CommentCard, CommentThread, DriftBadge, LabelPicker, ResolveControls, SuggestionDiff. Threading, suggestion diff rendering, and drift badges are deferred (see status doc). --- web/bun.lock | 7 +- web/package.json | 1 + web/src/app.css | 337 ++++++++++++- web/src/lib/paste/types.ts | 11 + web/src/routes/+layout.svelte | 70 +-- web/src/routes/share/[id]/+page.svelte | 447 ++++++++++++++---- .../[id]/components/AnnotationCard.svelte | 244 ++++++++++ .../[id]/components/AnnotationToolbar.svelte | 63 --- .../[id]/components/AnnotationsPanel.svelte | 78 +++ .../share/[id]/components/CommentCard.svelte | 34 -- .../[id]/components/CommentPopover.svelte | 147 ++++++ .../[id]/components/CommentThread.svelte | 21 - .../share/[id]/components/DriftBadge.svelte | 9 - .../[id]/components/FloatingToolbar.svelte | 196 ++++++++ .../share/[id]/components/LabelPicker.svelte | 26 - .../[id]/components/NamePromptModal.svelte | 52 +- .../share/[id]/components/PlanRenderer.svelte | 109 ++++- .../[id]/components/QuickLabelPicker.svelte | 85 ++++ .../[id]/components/ResolveControls.svelte | 42 -- .../[id]/components/SuggestionDiff.svelte | 10 - .../[id]/components/inline-annotations.ts | 119 +++++ 21 files changed, 1745 insertions(+), 363 deletions(-) create mode 100644 web/src/routes/share/[id]/components/AnnotationCard.svelte delete mode 100644 web/src/routes/share/[id]/components/AnnotationToolbar.svelte create mode 100644 web/src/routes/share/[id]/components/AnnotationsPanel.svelte delete mode 100644 web/src/routes/share/[id]/components/CommentCard.svelte create mode 100644 web/src/routes/share/[id]/components/CommentPopover.svelte delete mode 100644 web/src/routes/share/[id]/components/CommentThread.svelte delete mode 100644 web/src/routes/share/[id]/components/DriftBadge.svelte create mode 100644 web/src/routes/share/[id]/components/FloatingToolbar.svelte delete mode 100644 web/src/routes/share/[id]/components/LabelPicker.svelte create mode 100644 web/src/routes/share/[id]/components/QuickLabelPicker.svelte delete mode 100644 web/src/routes/share/[id]/components/ResolveControls.svelte delete mode 100644 web/src/routes/share/[id]/components/SuggestionDiff.svelte create mode 100644 web/src/routes/share/[id]/components/inline-annotations.ts diff --git a/web/bun.lock b/web/bun.lock index 6f52750..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", @@ -292,6 +293,8 @@ "@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=="], @@ -678,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=="], @@ -870,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 5fc9659..b9ea30c 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app.css b/web/src/app.css index 9e8272c..8c317de 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,335 @@ 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. + ========================================================================== */ + +.share-page { + --ink-paper: oklch(0.16 0.02 260); + --ink-paper-raised: oklch(0.2 0.015 260); + --ink-paper-edge: oklch(0.24 0.015 260); + --ink-text: oklch(0.92 0.02 85); + --ink-text-muted: oklch(0.72 0.02 85); + --ink-text-faint: oklch(0.55 0.02 260); + --ink-rule: oklch(0.3 0.015 260); + + /* Annotation inks */ + --ink-comment: oklch(0.78 0.16 75); /* warm amber - quill on parchment */ + --ink-comment-bg: oklch(0.78 0.16 75 / 0.16); + --ink-comment-edge: oklch(0.78 0.16 75 / 0.55); + --ink-delete: oklch(0.68 0.2 25); /* editorial red */ + --ink-delete-bg: oklch(0.68 0.2 25 / 0.18); + --ink-delete-edge: oklch(0.68 0.2 25 / 0.55); + --ink-praise: oklch(0.72 0.14 150); /* sage / proofreader green */ + --ink-praise-bg: oklch(0.72 0.14 150 / 0.16); + + font-family: "Newsreader", ui-serif, Georgia, serif; + font-feature-settings: "kern", "liga", "clig", "calt", "onum"; + 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 — the "page" being read */ +.share-page .doc { + font-family: "Newsreader", ui-serif, Georgia, serif; + font-size: 1.0625rem; /* 17px */ + line-height: 1.7; + color: var(--ink-text); +} +.share-page .doc h1, +.share-page .doc h2, +.share-page .doc h3, +.share-page .doc h4 { + font-family: "Newsreader", ui-serif, Georgia, serif; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink-text); + line-height: 1.25; +} +.share-page .doc h1 { + font-size: 2rem; + margin: 0 0 1.5rem 0; + font-weight: 600; + font-style: italic; + letter-spacing: -0.02em; +} +.share-page .doc h2 { + font-size: 1.375rem; + margin: 2.5rem 0 1rem 0; + border-bottom: 1px solid var(--ink-rule); + padding-bottom: 0.5rem; +} +.share-page .doc h3 { + font-size: 1.125rem; + margin: 2rem 0 0.75rem 0; +} +.share-page .doc h4 { + font-size: 1rem; + margin: 1.5rem 0 0.5rem 0; + color: var(--ink-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 0.85rem; +} +.share-page .doc p { + margin: 0 0 1rem 0; +} +.share-page .doc ul, +.share-page .doc ol { + margin: 0 0 1rem 0; + padding-left: 1.5rem; +} +.share-page .doc ul li { + list-style: none; + position: relative; +} +.share-page .doc ul li::before { + content: "·"; + position: absolute; + left: -1rem; + color: var(--ink-text-faint); + font-size: 1.4em; + line-height: 1; +} +.share-page .doc ol { + list-style: decimal; +} +.share-page .doc ol li::marker { + color: var(--ink-text-faint); + font-feature-settings: "onum"; +} +.share-page .doc li { + margin: 0.25rem 0; +} +.share-page .doc strong { + font-weight: 600; + color: var(--ink-text); +} +.share-page .doc em { + font-style: italic; +} +.share-page .doc code { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.875em; + background: var(--ink-paper-edge); + padding: 0.125rem 0.375rem; + border-radius: 3px; + color: var(--ink-text); +} +.share-page .doc pre { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.8125rem; + background: var(--ink-paper-edge); + padding: 1rem 1.25rem; + border-radius: 6px; + border: 1px solid var(--ink-rule); + overflow-x: auto; + line-height: 1.6; + margin: 1rem 0; +} +.share-page .doc pre code { + background: transparent; + padding: 0; + border-radius: 0; +} +.share-page .doc blockquote { + border-left: 2px solid var(--ink-text-faint); + padding: 0.25rem 0 0.25rem 1.25rem; + margin: 1rem 0; + color: var(--ink-text-muted); + font-style: italic; +} +.share-page .doc hr { + border: none; + border-top: 1px solid var(--ink-rule); + margin: 2rem 0; +} +.share-page .doc a { + color: var(--ink-comment); + text-decoration: underline; + text-decoration-color: var(--ink-comment-edge); + text-underline-offset: 3px; +} +.share-page .doc table { + border-collapse: collapse; + margin: 1rem 0; + font-family: "Inter Tight", ui-sans-serif, sans-serif; + font-size: 0.9rem; +} +.share-page .doc th, +.share-page .doc td { + border: 1px solid var(--ink-rule); + padding: 0.5rem 0.75rem; + text-align: left; +} +.share-page .doc th { + background: var(--ink-paper-raised); + font-weight: 600; +} + +/* 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 { + font-family: "Newsreader", ui-serif, Georgia, serif; + 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); +} + +/* Subtle paper grain — barely-there to break up the flat dark surface */ +.share-page .doc-area { + background: + radial-gradient(circle at 0 0, oklch(1 0 0 / 0.015) 1px, transparent 1px), + radial-gradient(circle at 12px 12px, oklch(1 0 0 / 0.012) 1px, transparent 1px), + var(--ink-paper); + background-size: + 24px 24px, + 24px 24px; +} + +/* 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/paste/types.ts b/web/src/lib/paste/types.ts index 55ca90c..acfdbbe 100644 --- a/web/src/lib/paste/types.ts +++ b/web/src/lib/paste/types.ts @@ -52,12 +52,23 @@ export type Anchor = { heading_slug?: string; }; +/** + * The reviewer's intent. Plannotator parity: COMMENT vs DELETION are the + * primary actions. `comment_type` (praise/issue/etc.) is a secondary label. + * Body is required for action='comment' but optional for 'delete' — the + * strikethrough IS the action. + */ +export type AnnotationAction = 'comment' | 'delete'; + export type CommentEvent = { kind: 'comment'; id: string; author_name: string; + /** Primary intent. Defaults to 'comment' for back-compat with v1 events. */ + action?: AnnotationAction; comment_type: CommentType; severity?: Severity; + /** Required for action='comment'; may be empty for action='delete'. */ body: string; suggested_text?: string; parent_id?: string; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 4988f3d..797c439 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -19,9 +19,16 @@ const currentProjectId = $derived($page.params.projectId); const currentProject = $derived($projectsStore.find((p) => p.id === currentProjectId)); - // Load projects on mount + // /share/[id] is a focused review surface that opts out of arc's app shell. + // On a public arc-paste deploy, /api/v1/projects doesn't exist, so loading + // projects there would put the entire SPA into an error state and the + // share page would never render. Even on local arc-server, hiding arc's + // chrome keeps the share URL behaving consistently across hosts. + const isShareRoute = $derived($page.url.pathname.startsWith('/share/')); + + // Load projects on mount (skipped on /share routes) $effect(() => { - loadProjects(); + if (!isShareRoute) loadProjects(); }); async function loadProjects() { @@ -45,33 +52,38 @@ -
    - +{#if isShareRoute} + + {@render children()} +{:else} +
    + -
    - {#if $loadingStore} -
    -
    Loading...
    -
    - {:else if $errorStore} -
    -
    -
    - - - +
    + {#if $loadingStore} +
    +
    Loading...
    +
    + {:else if $errorStore} +
    +
    +
    + + + +
    +

    Connection Error

    +

    {$errorStore}

    +
    -

    Connection Error

    -

    {$errorStore}

    -
    -
    - {:else} - {@render children()} - {/if} -
    -
    + {:else} + {@render children()} + {/if} + +
    +{/if} diff --git a/web/src/routes/share/[id]/+page.svelte b/web/src/routes/share/[id]/+page.svelte index e452848..f90987b 100644 --- a/web/src/routes/share/[id]/+page.svelte +++ b/web/src/routes/share/[id]/+page.svelte @@ -9,134 +9,397 @@ EventPlaintext, CommentEvent, CommentType, - Severity, + ResolutionEvent, + ResolutionStatus, Anchor } from '$lib/paste/types'; import PlanRenderer from './components/PlanRenderer.svelte'; - import AnnotationToolbar from './components/AnnotationToolbar.svelte'; - import CommentCard from './components/CommentCard.svelte'; + import FloatingToolbar, { type ToolbarAction } from './components/FloatingToolbar.svelte'; + import CommentPopover, { type PopoverMode } from './components/CommentPopover.svelte'; + import QuickLabelPicker from './components/QuickLabelPicker.svelte'; + import AnnotationsPanel from './components/AnnotationsPanel.svelte'; import NamePromptModal from './components/NamePromptModal.svelte'; + import type { InlineMark } from './components/inline-annotations.ts'; - const { data } = $props<{ data: { id: string } }>(); + const { data }: { data: { id: string } } = $props(); + + // --- Decrypted state --- let plan = $state(null); let comments = $state(new Map()); let key = $state(null); - let reviewerName = $state(getReviewerName()); + let loadError = $state(null); + + // --- Reviewer identity --- + let reviewerName = $state(null); let showNamePrompt = $state(false); - let pendingComment = $state<{ - body: string; - comment_type: CommentType; - severity?: Severity; - suggested_text?: string; - anchor: Anchor; - } | null>(null); - let selection = $state<{ + let pendingAfterName: (() => void) | null = null; + + // --- UI state for selection-driven actions --- + type SelectionInfo = { lineStart: number; lineEnd: number; - charStart?: number; - charEnd?: number; quotedText: string; headingSlug?: string; contextBefore?: string; contextAfter?: string; - } | undefined>(undefined); - - const anchor = $derived( - selection - ? ({ - line_start: selection.lineStart, - line_end: selection.lineEnd, - char_start: selection.charStart, - char_end: selection.charEnd, - quoted_text: selection.quotedText, - heading_slug: selection.headingSlug, - context_before: selection.contextBefore, - context_after: selection.contextAfter - } satisfies Anchor) - : undefined + rect: DOMRect; + }; + let activeSelection = $state(null); + let popoverMode = $state(null); + let showQuickLabel = $state(false); + let activeMarkId = $state(undefined); + + let client: PasteClient | undefined; + + const isAuthor = $derived( + reviewerName !== null && + plan !== null && + !!plan.author_name && + plan.author_name === reviewerName ); - const client = new PasteClient(window.location.origin); + const orderedStates = $derived.by(() => { + return [...comments.values()].sort((a, b) => + b.event.created_at.localeCompare(a.event.created_at) + ); + }); + + const marks = $derived.by((): InlineMark[] => { + const out: InlineMark[] = []; + for (const state of comments.values()) { + if (state.status === 'resolved' || state.status === 'rejected') continue; + out.push({ + id: state.event.id, + kind: state.event.action === 'delete' ? 'delete' : 'comment', + lineStart: state.event.anchor.line_start, + lineEnd: state.event.anchor.line_end, + quotedText: state.event.anchor.quoted_text + }); + } + return out; + }); onMount(async () => { - const fragment = window.location.hash.replace(/^#/, ''); - const params = new URLSearchParams(fragment); - const k = params.get('k'); - if (!k) { - console.error('missing #k= in URL'); - return; - } - key = await importKey(k); - - const resp = await client.get(data.id); - plan = await decryptJSON(b64ToBytes(resp.plan_blob), b64ToBytes(resp.plan_iv), key); - - const events: EventPlaintext[] = []; - for (const ev of resp.events) { - const { blob, iv } = eventBytes(ev); - try { - events.push(await decryptJSON(blob, iv, key)); - } catch { - // skip undecryptable events (corrupt or tampered) + reviewerName = getReviewerName(); + client = new PasteClient(window.location.origin); + + try { + const fragment = window.location.hash.replace(/^#/, ''); + const params = new URLSearchParams(fragment); + const k = params.get('k'); + if (!k) { + loadError = 'Missing #k= in URL — share link is incomplete.'; + return; + } + key = await importKey(k); + + const resp = await client.get(data.id); + plan = await decryptJSON( + b64ToBytes(resp.plan_blob), + b64ToBytes(resp.plan_iv), + key + ); + + const events: EventPlaintext[] = []; + for (const ev of resp.events) { + const { blob, iv } = eventBytes(ev); + try { + events.push(await decryptJSON(blob, iv, key)); + } catch { + // skip undecryptable events + } } + comments = replayEvents(plan?.author_name, events); + } catch (err) { + loadError = (err as Error)?.message ?? 'Failed to load share'; } - comments = replayEvents(plan?.author_name, events); }); - async function handleSubmitComment(payload: typeof pendingComment) { - if (!payload || !key || !plan) return; - if (!reviewerName) { - pendingComment = payload; - showNamePrompt = true; + function ensureName(after: () => void) { + if (reviewerName) { + after(); return; } + pendingAfterName = after; + showNamePrompt = true; + } + + function handleNameSaved(name: string) { + reviewerName = name; + showNamePrompt = false; + const cb = pendingAfterName; + pendingAfterName = null; + cb?.(); + } + + function clearSelection() { + activeSelection = null; + popoverMode = null; + showQuickLabel = false; + const sel = window.getSelection(); + sel?.removeAllRanges(); + } + + async function postEvent(event: EventPlaintext) { + if (!key || !client) return; + const { blob, iv } = await encryptJSON(event, key); + await client.appendEvent(data.id, blob, iv); + } + + function buildAnchor(sel: SelectionInfo): Anchor { + return { + line_start: sel.lineStart, + line_end: sel.lineEnd, + quoted_text: sel.quotedText, + heading_slug: sel.headingSlug, + context_before: sel.contextBefore, + context_after: sel.contextAfter + }; + } + + async function createComment(opts: { + body: string; + comment_type?: CommentType; + action?: 'comment' | 'delete'; + suggested_text?: string; + anchor: Anchor; + }) { + if (!reviewerName) return; const event: CommentEvent = { kind: 'comment', id: `c-${crypto.randomUUID()}`, author_name: reviewerName, - comment_type: payload.comment_type, - severity: payload.severity, - body: payload.body, - suggested_text: payload.suggested_text, - anchor: payload.anchor, + action: opts.action ?? 'comment', + comment_type: opts.comment_type ?? 'comment', + body: opts.body, + suggested_text: opts.suggested_text, + anchor: opts.anchor, created_at: new Date().toISOString() }; - const { blob, iv } = await encryptJSON(event, key); - await client.appendEvent(data.id, blob, iv); + await postEvent(event); const next = new Map(comments); next.set(event.id, { event, status: 'open' }); comments = next; - pendingComment = null; + } + + async function handleToolbarAction(action: ToolbarAction) { + if (!activeSelection) return; + const sel = activeSelection; + + switch (action) { + case 'praise': + ensureName(async () => { + await createComment({ + body: 'Looks good', + comment_type: 'praise', + action: 'comment', + anchor: buildAnchor(sel) + }); + clearSelection(); + }); + return; + case 'comment': + ensureName(() => { + popoverMode = 'comment'; + }); + return; + case 'delete': + ensureName(async () => { + await createComment({ + body: '', + comment_type: 'comment', + action: 'delete', + anchor: buildAnchor(sel) + }); + clearSelection(); + }); + return; + case 'suggest': + ensureName(() => { + popoverMode = 'suggest'; + }); + return; + case 'quick-label': + ensureName(() => { + showQuickLabel = true; + }); + return; + } + } + + async function handlePopoverSave(body: string, suggestedText?: string) { + if (!activeSelection) return; + await createComment({ + body, + comment_type: suggestedText ? 'suggestion' : 'comment', + action: 'comment', + suggested_text: suggestedText, + anchor: buildAnchor(activeSelection) + }); + clearSelection(); + } + + async function handleQuickLabelPick(label: CommentType, presetBody: string) { + if (!activeSelection) return; + const sel = activeSelection; + showQuickLabel = false; + if (presetBody) { + await createComment({ + body: presetBody, + comment_type: label, + action: 'comment', + anchor: buildAnchor(sel) + }); + clearSelection(); + } else { + // No preset — open the comment popover so the user can write a body + popoverMode = 'comment'; + } + } + + async function handleResolve(commentId: string, status: ResolutionStatus, reply?: string) { + if (!reviewerName) return; + const event: ResolutionEvent = { + kind: 'resolution', + id: `r-${crypto.randomUUID()}`, + comment_id: commentId, + status, + reply, + author_name: reviewerName, + created_at: new Date().toISOString() + }; + await postEvent(event); + const next = new Map(comments); + const target = next.get(commentId); + if (target && plan?.author_name === reviewerName) { + next.set(commentId, { ...target, status, reply, replyAt: event.created_at }); + } + comments = next; + } + + function handleSelection(sel: SelectionInfo | null) { + if (!sel) { + if (!popoverMode && !showQuickLabel) activeSelection = null; + return; + } + activeSelection = sel; + popoverMode = null; + showQuickLabel = false; + } + + function handleMarkClick(id: string) { + activeMarkId = activeMarkId === id ? undefined : id; + document + .querySelector(`[data-anno-card-id="${id}"]`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + function handleCardClick(id: string) { + activeMarkId = activeMarkId === id ? undefined : id; } -{#if !plan} -

    Loading…

    -{:else} -
    - { - selection = a; - }} + + {plan?.title ?? 'Plan review'} · arc + + + diff --git a/web/src/routes/share/[id]/components/AnnotationCard.svelte b/web/src/routes/share/[id]/components/AnnotationCard.svelte new file mode 100644 index 0000000..7aac5f8 --- /dev/null +++ b/web/src/routes/share/[id]/components/AnnotationCard.svelte @@ -0,0 +1,244 @@ + + +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} +> +
    +
    + + {e.author_name} + {#if isMine} + (me) + {/if} +
    + + {timeLabel(e.created_at)} + +
    + +
    + + + {chipLabel} + + {#if entry.status !== 'open'} + + · {entry.status} + + {/if} +
    + + +
    + "{e.anchor.quoted_text}" +
    + + + {#if e.suggested_text} +
    +
    + Replacement +
    +
    + {e.suggested_text} +
    +
    + {#if e.body} +
    {e.body}
    + {/if} + {:else if e.body} +
    {e.body}
    + {/if} + + {#if entry.reply} +
    + ↪ {entry.reply} +
    + {/if} + + {#if isAuthor && !showRejectReply} +
    + {#if entry.status !== 'accepted'} + + {/if} + {#if entry.status !== 'resolved'} + + {/if} + {#if entry.status !== 'rejected'} + + {/if} + {#if entry.status !== 'open' && entry.status !== 'reopened'} + + {/if} +
    + {/if} + + {#if showRejectReply} +
    + +
    + + +
    +
    + {/if} +
    diff --git a/web/src/routes/share/[id]/components/AnnotationToolbar.svelte b/web/src/routes/share/[id]/components/AnnotationToolbar.svelte deleted file mode 100644 index dd593e7..0000000 --- a/web/src/routes/share/[id]/components/AnnotationToolbar.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -
    - - - {#if commentType === 'issue'} - - {/if} - - {#if showSuggested} - - {/if} - -
    diff --git a/web/src/routes/share/[id]/components/AnnotationsPanel.svelte b/web/src/routes/share/[id]/components/AnnotationsPanel.svelte new file mode 100644 index 0000000..1c450d5 --- /dev/null +++ b/web/src/routes/share/[id]/components/AnnotationsPanel.svelte @@ -0,0 +1,78 @@ + + + diff --git a/web/src/routes/share/[id]/components/CommentCard.svelte b/web/src/routes/share/[id]/components/CommentCard.svelte deleted file mode 100644 index 0c504a6..0000000 --- a/web/src/routes/share/[id]/components/CommentCard.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -
  • -
    - {e.author_name} - {e.comment_type}{e.severity ? ` · ${e.severity}` : ''} -
    -

    {e.body}

    - {#if e.suggested_text} - - {/if} - -
    - {state.status} - {#if isAuthor} - { - /* parent wires up */ - }} - /> - {/if} -
    -
  • diff --git a/web/src/routes/share/[id]/components/CommentPopover.svelte b/web/src/routes/share/[id]/components/CommentPopover.svelte new file mode 100644 index 0000000..44b7071 --- /dev/null +++ b/web/src/routes/share/[id]/components/CommentPopover.svelte @@ -0,0 +1,147 @@ + + + diff --git a/web/src/routes/share/[id]/components/CommentThread.svelte b/web/src/routes/share/[id]/components/CommentThread.svelte deleted file mode 100644 index 4a0f6cc..0000000 --- a/web/src/routes/share/[id]/components/CommentThread.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -
    - - {#if replies.length} -
      - {#each replies as r (r.event.id)} - - {/each} -
    - {/if} -
    diff --git a/web/src/routes/share/[id]/components/DriftBadge.svelte b/web/src/routes/share/[id]/components/DriftBadge.svelte deleted file mode 100644 index 5eca9fa..0000000 --- a/web/src/routes/share/[id]/components/DriftBadge.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -{#if status === 'drifted'} - anchor moved -{:else if status === 'orphaned'} - content removed -{/if} diff --git a/web/src/routes/share/[id]/components/FloatingToolbar.svelte b/web/src/routes/share/[id]/components/FloatingToolbar.svelte new file mode 100644 index 0000000..0fb6e15 --- /dev/null +++ b/web/src/routes/share/[id]/components/FloatingToolbar.svelte @@ -0,0 +1,196 @@ + + + diff --git a/web/src/routes/share/[id]/components/LabelPicker.svelte b/web/src/routes/share/[id]/components/LabelPicker.svelte deleted file mode 100644 index 6bc97fa..0000000 --- a/web/src/routes/share/[id]/components/LabelPicker.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
    - {#each labels as l} - - {/each} -
    diff --git a/web/src/routes/share/[id]/components/NamePromptModal.svelte b/web/src/routes/share/[id]/components/NamePromptModal.svelte index efb81b0..e922a18 100644 --- a/web/src/routes/share/[id]/components/NamePromptModal.svelte +++ b/web/src/routes/share/[id]/components/NamePromptModal.svelte @@ -1,8 +1,10 @@ -