Skip to content

feat(shares): move share keyring from ~/.arc/shares.json to data.db - #41

Merged
bfirestone merged 13 commits into
mainfrom
feat/share-keyring-sqlite
May 1, 2026
Merged

feat(shares): move share keyring from ~/.arc/shares.json to data.db#41
bfirestone merged 13 commits into
mainfrom
feat/share-keyring-sqlite

Conversation

@bfirestone

Copy link
Copy Markdown
Contributor

Summary

Migrate the author-side share keyring (currently ~/.arc/shares.json) into the arc-server's SQLite database (~/.arc/data.db), exposed via /api/v1/shares. Both the CLI and a future webui share-management UI talk to the same endpoints. The CLI's user-visible behavior is unchanged.

The original branch also includes the earlier arc share delete --force work (5410482, 7a4c873) that hadn't been PR'd yet — it stays bundled here since the keyring rewrite supersedes the file-handling code those commits touched.

Why

shares.json is a client-side keyring of edit tokens. Today only the CLI reads it; the local arc webui can't (browsers don't reach the filesystem). The planned QoL feature of webui-driven share management requires the keyring to live where the daemon owns it — data.db — so multiple clients can read and mutate it through a single API surface.

Storage format alone wouldn't have justified the change. The multi-client requirement is what tips it.

What changed

  • New shares table (migration 017_shares.sql) with CHECK(kind IN ('local','shared')) and created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP matching every other table in the codebase.
  • storage.Storage interface gains UpsertShare/UpsertShares/GetShare/ListShares/DeleteShare plus an ErrShareNotFound sentinel. UpsertShares is transactional so legacy imports are atomic.
  • /api/v1/shares endpoints (internal/api/shares.go) mirroring the labels handler pattern. OpenAPI spec updated; Go bindings + TypeScript types regenerated.
  • internal/client/shares.go CLI HTTP client.
  • internal/sharesconfig rewritten as a thin HTTP shim that preserves the public API (Add/Find/Load/Remove/Share/File/ErrShareNotFound) so cmd/arc/share.go callers compile unchanged. Client factory injected from cmd/arc/main.go init().
  • One-shot legacy import at arc-server startup: reads ~/.arc/shares.json, atomically upserts via UpsertShares, renames to shares.json.bak on success. File-presence is the idempotency signal — partial commits are no longer possible because the batch is transactional, and the next startup retries cleanly if the operator fixes a bad entry.
  • User-facing CLI strings updated from ~/.arc/shares.json references to "the local arc keyring".
  • arc share delete --force flag (from earlier work on this branch) cleans up orphaned local entries when the server has already deleted the share.

Architecture decisions

  • Why /api/v1/shares (vs /api/client-shares or /api/planner/shares) — matches the existing flat resource pattern under /api/v1 (issues, labels, plans). The /api/paste/ blob host stays intentionally unversioned.
  • Why the keyring lives in arc-server (vs a separate sqlite file) — daemon's value props (multi-client state, concurrency, transactions) only pay off here because of the webui use case. For pure CLI use, JSON would still be the right tool.
  • Why plaintext edit_token storage — these are bearer tokens we hand back to clients; hashing makes the keyring useless. Same security posture as the JSON file (file mode 0600 → API on 127.0.0.1 with same trust boundary).

Review-driven cleanups in this branch

Three rounds of review iteration are in the history:

  1. After T6 landed, a slop review flagged 11 issues. Eight got fixed in b3ceed2 (schema TEXT/STRICT → TIMESTAMP, em-dash → ASCII, route dedup, read-after-write removal, stale shares.json strings, etc.). The skipped findings are documented in that commit's message.
  2. A code review then escalated one finding the slop review had downgraded: the import loop was non-atomic and the row-count guard locked out recovery. 0378177 adds Storage.UpsertShares (transactional batch) and replaces the row-count guard with file-presence semantics.
  3. 8f045ef resolves all make lint findings the changes introduced (errcheck, gosec G306, revive, testifylint, testpackage).

Test plan

  • go test ./... — full suite green (14 packages)
  • make lint — 0 issues
  • make build — clean
  • make gen — regenerated bindings committed
  • OpenAPI spec round-trip: api/openapi.yamlinternal/api/openapi.gen.go + web/src/lib/api/types.ts
  • Storage layer: 9 unit tests for shares CRUD + upsert-replace + idempotent-delete + transactional batch
  • API layer: 11 handler tests covering 200/400/404/204 paths + upsert + server-stamped CreatedAt + newest-first list
  • CLI client: 5 tests including errors.Is(err, ErrShareNotFound) round-trip
  • Legacy import: 6 tests covering no-file / valid-import / idempotent-upsert / malformed-json / atomic-rollback / .bak-overwrite
  • sharesconfig shim: 6 tests via injected fake client
  • cmd/arc integration: existing arc share * round-trip tests pass against the new endpoints

Add Share/ShareKind types with Validate/IsValid methods to internal/types,
four new methods on the storage.Storage interface (UpsertShare/GetShare/
ListShares/DeleteShare) with ErrShareNotFound sentinel, migration 017_shares.sql
with goose pragmas, schema.sql update, /shares OpenAPI endpoints with
regenerated Go and TypeScript bindings, and authorized stub Store methods.
Replace T0 stubs in shares.go with real sqlc-backed implementations.
Add shares.sql queries, regenerate shares.sql.go, and write unit tests
covering upsert (insert + replace), get (found + ErrShareNotFound),
list (ordered newest first + empty), and delete (remove + idempotent).
Implements listShares, getShare, upsertShare, and deleteShare handlers
in internal/api/shares.go, registers routes in server.go, and covers
all 10 spec-required scenarios in shares_test.go (200/400/404/204
status codes, upsert replace semantics, idempotent delete, newest-first
ordering).
Implement four methods on *Client to wrap the shares API endpoints:
- ListShares: returns all keyring entries
- GetShare: retrieves a share by ID, returns ErrShareNotFound on 404
- UpsertShare: inserts/replaces a share and returns server-stamped record
- DeleteShare: removes a share, idempotent on 404

Includes comprehensive test coverage using httptest.NewServer pattern
matching labels.go style. All tests pass; whole codebase builds.
Add assertion that verifies the request body sent by UpsertShare client method
matches the input share fields exactly. The test now creates a custom test server
that captures the decoded request body and asserts each field (ID, Kind, URL,
KeyB64Url, EditToken, PlanFile) matches what was passed to UpsertShare.
Replace file-based share registry with a thin HTTP wrapper over the new
/api/v1/shares endpoints. Preserves the full public API (Add, Find, Load,
Remove, Share, File, ErrShareNotFound) so cmd/arc/share.go callers compile
unchanged. Adds SetClientFactory indirection for test injection, and
LegacyPath() for T6 startup wiring. Tests use injected fakeClient; no temp dirs.
… endpoints (T6)

Wires together the work from T0-T5 so the share keyring runs end-to-end
through arc-server.

- internal/server: invoke ImportLegacySharesJSON after the api.Server is
  constructed and before the listener starts so a one-shot migration of
  ~/.arc/shares.json into the shares table runs at startup. Idempotent
  (skips when the table is non-empty) and renames the JSON to .bak on
  success so subsequent boots are no-ops.
- cmd/arc/main.go: install sharesconfig.SetClientFactory in init() so
  every `arc share *` invocation has a live HTTP client. The factory is
  lazy — getClient() is called at command time, not process start.
- internal/api/server: expose a small RegisterShareRoutes(g) helper so
  test fixtures outside the api package can mount the keyring routes
  without recreating the full registerRoutes() surface.
- cmd/arc/share_test.go: extend startTestPasteServer to also stand up an
  in-process /api/v1/shares against a real sqlite store backed by the
  full migration set (017_shares.sql included). Each test gets a fresh
  client factory pointed at the test server, restored on cleanup.
Schema cleanup (QUAL-1, QUAL-2, MISS-3):
- Convert created_at TEXT/STRICT to TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  matching every other table in the codebase. sqlc now generates time.Time
  natively, so the silent t, _ := time.Parse(...) error swallow in rowToShare
  goes away entirely.
- Add CHECK (kind IN ('local', 'shared')) constraint at the schema level since
  Validate() already enforces it at the type level.
- Move the IsZero -> time.Now() default into Store.UpsertShare so HTTP handlers
  and the legacy import path share one default-stamping site.

Sentinel handling (QUAL-3, AUTH-2):
- Test fakeClient now returns client.ErrShareNotFound directly instead of
  errors.New("share not found"), so the err.Error() string-equality fallback
  in sharesconfig.Find can be deleted in favor of plain errors.Is.
- Replace U+2014 em-dash with ASCII colon in the factory-not-initialized error
  message.

Route dedup (QUAL-4):
- registerRoutes calls s.RegisterShareRoutes(v1) instead of repeating the four
  route registrations inline.

Handler cleanup (AUTH-1):
- Drop the read-after-write GetShare in upsertShare. The store stamps
  CreatedAt on the passed-in *types.Share, so the same struct already has the
  correct value when we return it.

User-facing strings (QUAL-8):
- Replace "~/.arc/shares.json" wording in cobra Short descriptions, Println
  output, and CLI error messages with "the local arc keyring", reflecting
  the post-migration storage. Code comments referencing the legacy file are
  also updated.

Test cleanup (AUTH-3, IDIOM-2):
- Remove the boilerplate "// TestX verifies ..." doc comments from
  shares_test.go that mechanically restate each test's name.
- Replace map[string]interface{} with map[string]any and 0644/0600/0700
  literals with 0o644/0o600/0o700 in shares_import_test.go to match the
  rest of the codebase (Go 1.18+ idioms).

Skipped findings (defensible or out of scope):
- QUAL-5 (use c.get/c.delete helpers in shares client): the helpers run
  checkError before returning, which closes the body and string-formats the
  error, leaving no clean way to express 404-aware semantics without string
  matching. The status-aware bypass in GetShare/DeleteShare is the right
  shape given the helper API.
- QUAL-6: 3-package ErrShareNotFound — already downgraded by the reviewer.
- QUAL-7 (transaction in import loop): defensible. Import is one-shot and
  recoverable; changing the abort-on-validation-failure semantic would
  require updating the existing test contract for marginal benefit.
- MISS-1 (wasteful api.New in test fixture): would require a new public
  constructor for marginal allocation savings.
- MISS-2 (context.Background in startup): borderline; startup is short-lived.
- IDIOM-1 (testify in client tests vs stdlib in api tests): pre-existing
  pattern conflict not introduced by this branch.
The original importer had two problems that combined into an unrecoverable
upgrade path:

  1. The loop ran outside any transaction, so a per-entry validation or
     constraint failure left entries 0..N-1 durably committed while the
     .bak rename never happened.
  2. The "skip if shares table has rows" guard then prevented the next
     startup from retrying — the partial state silently shadowed the
     remaining legacy entries forever.

Replace both with a single transactional batch:

- Add Storage.UpsertShares([]*types.Share) — wraps the per-row UpsertShare
  calls in BeginTx + WithTx + Commit, so any validation failure rolls the
  whole batch back. The old single-entry UpsertShare delegates to the same
  shared body so behavior outside the import path is unchanged.
- Drop the row-count guard. The natural idempotency signal is the file
  itself: presence means "import me," absence (or a successful import that
  renamed it to .bak) means "nothing to do." This makes user-restored
  shares.json files re-importable cleanly via upsert semantics.

Test contract changes:
- TestImportLegacySharesJSONValidationFailureMidImport now asserts atomic
  rollback (0 rows, file un-renamed, error returned) instead of the broken
  partial-write behavior.
- TestImportLegacySharesJSONNonEmptyTable replaced by
  TestImportLegacySharesJSONIdempotentUpsert, which exercises the new
  contract: pre-existing rows that aren't in the file are preserved, rows
  that are in both get overwritten, and the .bak rename still happens.

Found by code review on b3ceed2.
- errcheck: handle json.Encoder.Encode error in client/shares_test.go
- gosec G306: tighten shares_import_test temp-file perms 0o644 -> 0o600
- revive confusing-naming: rename helper upsertShare -> upsertShareWith
  so it no longer differs from UpsertShare only by capitalization
- testifylint error-is-as: assert.True(t, errors.Is(...)) ->
  assert.ErrorIs(t, err, ...) (drops the unused errors import too)
- testpackage: switch internal/types/shares_test.go and
  internal/sharesconfig/sharesconfig_test.go to *_test packages, since
  every symbol they touch is already exported
@bfirestone
bfirestone merged commit 8bdd018 into main May 1, 2026
3 checks passed
@bfirestone
bfirestone deleted the feat/share-keyring-sqlite branch May 1, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant