33 Commits

Author SHA1 Message Date
adlee-was-taken
d2d11a4c9f chore: release v0.6.0
Rolls up four weeks of post-v0.5.0 work into one tag:

- Phase 2B polish foundation + form layout (2026-05-02, 5da1e52)
- v0.5.1 Stream A — 3-column vault layout + toast + bottom sheet (2026-05-03, c16adc4)
- v0.5.1 Stream B — left-nav settings (2026-05-03, bd6a301)
- v0.5.1 Stream C — Recovery QR + setup wizard Style C (2026-05-03, 934dfe0)
- 1C-γ — Document item type + attachments + device registration + trash + history
- Plan B refactor (Cycles 1+2) — commands/ split, prompt_or_flag, core/WASM seam
- Vault-tab management surfaces revamp (2026-05-24..30) — settings split, devices fingerprint, trash countdown, history polish
- Doc-structure redesign (2026-05-30) — DESIGN/CRYPTO/docs/FORMATS rename + scope headers + Next: footers
- Lock-screen logo for parity with popup unlock
- 17 stale tests updated to match post-Stream-B / post-revamp components

Versions: relicario-{core,cli,wasm} → 0.6.0; extension/package.json → 0.6.0.
relicario-server stays at 0.1.0 (separate cadence).

Suite status at tag time: 371/371 extension + 281 Rust tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:17:54 -04:00
adlee-was-taken
361f3b4368 fix(ext/tests): router register_this_device test references current API
The router migrated from generate_device_keypair → register_device
(returns signing_public_key + deploy_public_key with private keys
staying internal to WASM). Test still mocked the old function under
the old return shape (public_key_hex / private_key_base64), so the
router's state.wasm.register_device() call failed with
"is not a function".

Updates the mock function name, response shape, and assertion to the
current contract. Test intent (treat the WASM return as a JS object,
not a JSON string) is preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:16:14 -04:00
adlee-was-taken
c9802ef392 fix(ext/tests): update settings.test.ts for left-nav settings + revamp
Tests were written against the pre-Stream B flat settings page. After
the left-nav restructure (bd6a301) and the management-surfaces revamp,
the Display section's IDs are only in the DOM once the user navigates
there, and renderSettings makes additional sendMessage calls (is_unlocked,
per-section data) that the original mocks didn't cover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:14:36 -04:00
adlee-was-taken
797709b441 fix(ext/tests): update devices tests for revamp (fingerprint + two-step revoke)
Tests predated the 2026-05-24 management-surfaces revamp (047df6e): popup
devices pane now shows SHA-256 fingerprint + added-by + inline two-step
revoke confirm, and the SW revokeDevice signature may have shifted to
match. Mocks + assertions updated accordingly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:27:51 -04:00
adlee-was-taken
0bde0935c2 docs: STATUS/ROADMAP — close out post-audit cleanup iteration
Three commits landed since the prior sync (72a59c6) that should be
reflected here:
- cccb7d7  rule #4 + doc-structure plan ticks
- 39ae629  vault lock-screen logo
- (this commit)

Moves the doc-structure redesign from "in progress" to "complete"
(Task 5 verified clean), drops the lock-screen logo from in-flight,
and trims Up next to the four genuinely-outstanding items: tag cut,
CLI restructure, extension restructure, security polish.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:12:41 -04:00
adlee-was-taken
39ae629894 feat(ext/vault): add brand logo to lock screen
Mirrors the logo-lockup treatment already used in the popup unlock view
(Phase 2B) and the setup wizard. Lock-screen rendering now shows the
relicario-logo.svg above the wordmark instead of just the wordmark.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:11:46 -04:00
adlee-was-taken
cccb7d7ff3 docs: add CLAUDE.md rule #4 (plan-state hygiene) + tick doc-structure plan
Rule #4 codifies the discipline that prevents the kind of drift the
2026-05-30 status-audit found: Phase 2B, v0.5.1 Streams A/B/C, and 1C-γ
all stealth-shipped 2-3 weeks earlier with their plan checkboxes never
ticked and STATUS.md still listing them as "Up next".

Two halves to the rule:
- Ship side: ticking the boxes is part of shipping. A commit that lands
  plan work also ticks that plan's boxes (or an immediately-following
  docs commit does).
- Execute side: before starting an unchecked plan, spot-check git log
  for distinctive symbols/files — re-executing already-merged work is
  the worst failure mode of the drift.

Also applies the rule retroactively to the doc-structure redesign plan:
all 37 sub-step checkboxes flipped to [x]. Tasks 1-4 (rename, scope
headers + Next: footers, link fixes, CLAUDE.md table) shipped in
36a59cd..bae3f7c. Task 5's six verification steps all pass (Step 3's
grep matches are false positives — they're correct new-path sibling
links from inside docs/ to docs/, not stale old-path uses).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:11:33 -04:00
adlee-was-taken
72a59c666d docs: sync STATUS / ROADMAP with three weeks of stealth-shipped work
The 2026-05-30 sync commit (fa659eb) only covered the vault-tab
management surfaces revamp. It missed three earlier merges that landed
2026-05-02..05-03 and have been on main since:

- Phase 2B polish foundation + form layout (5da1e52, 2026-05-02)
- v0.5.1 Stream A — 3-column vault layout + bottom sheet + toast +
  GLYPH_VAULT_TAB + emoji sweep (c16adc4, 2026-05-03)
- v0.5.1 Stream B — left-nav settings (Autofill / Display / Security /
  Generator / Retention / Backup / Import) (bd6a301, 2026-05-03)
- v0.5.1 Stream C — Recovery QR end-to-end (core + WASM + CLI +
  settings-security.ts + setup wizard banner) + setup wizard Style C
  redesign (934dfe0, 2026-05-03)

Also missing: 1C-γ (attachments + Document type + device registration
+ trash + history), Plan B multi-stream refactor (Cycles 1+2), and
the in-flight doc-structure redesign Tasks 1-4 (commits 36a59cd..bae3f7c
since spec 3209bfb).

STATUS now lists each train with merge SHA, spec/plan pointers, and
per-feature bullets. ROADMAP's "Up next" / "Medium-term" / "Long-term"
sections retrimmed: the only genuinely outstanding work is doc-structure
Task 5 verification, the lock-screen logo, the v0.5.x tag, and the
three 2026-05-04 architecture-review specs (CLI restructure, extension
restructure, security polish — none have plans yet).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:56:22 -04:00
adlee-was-taken
bae3f7c946 docs(CLAUDE.md): update living-docs table + add discipline rules
Table row labels now reference DESIGN.md / docs/CRYPTO.md /
docs/FORMATS.md. Adds three new discipline rules attacking the
structural causes of the 2026-05-30 drift audit findings:

  1. Scope-boundary check — content goes in the doc whose scope
     header claims it; if it doesn't fit, move it instead of
     stretching the header.
  2. Code-constant pinning — docs that cite code constants must
     cite source file + line; constant changes update doc and
     code in the same commit.
  3. New-doc rule — adding a tour doc also requires updating
     DESIGN's code-map, the Next: footer chain, and this table.

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 18:11:06 -04:00
adlee-was-taken
01377e7b59 docs: fix incoming links to renamed/moved doc paths
Rewrites every markdown reference to the old paths:
- ARCHITECTURE.md → DESIGN.md
- docs/ARCHITECTURE.md → docs/CRYPTO.md
- FORMATS.md → docs/FORMATS.md

Touches CLAUDE.md (living-docs table + planning-references list),
per-crate ARCHITECTURE.md cross-refs, and any specs in
docs/superpowers/specs/ that referenced the old paths. Audit
history and test-run logs intentionally left untouched.

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 18:09:30 -04:00
adlee-was-taken
5e7023fcc1 docs: add scope headers + Next: footers to all tour docs
Each of the eight tour docs (README, DESIGN, docs/CRYPTO,
docs/FORMATS, docs/SECURITY, crates/relicario-core/ARCHITECTURE,
crates/relicario-cli/ARCHITECTURE, extension/ARCHITECTURE) now
declares its scope in a blockquote under its H1 and ends with a
single-line "Next:" pointer to the next doc in the canonical
reading order: README → DESIGN → CRYPTO → FORMATS → SECURITY →
core → cli → extension.

Also trimmed README's mid-section "Architecture" stub to a one-
paragraph pointer at DESIGN.md (was duplicating cross-codebase
content and referencing a non-existent docs/architecture/ tree).

Renamed docs/CRYPTO.md's H1 from "Relicario — Architecture" to
"Relicario — Crypto Pipeline" to match the file's renamed scope.

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 15:36:46 -04:00
adlee-was-taken
36a59cd564 docs: rename for doc-structure redesign — DESIGN / CRYPTO / docs/FORMATS
Mechanical renames only; no content changes. Tracked as renames so
git blame / git log --follow survive intact.

- ARCHITECTURE.md → DESIGN.md (top-level system tour)
- docs/ARCHITECTURE.md → docs/CRYPTO.md (crypto pipeline)
- FORMATS.md → docs/FORMATS.md (wire formats; aligns with docs/ layout)

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 15:29:12 -04:00
adlee-was-taken
9ffb0f108b docs(plan): doc-structure redesign — 5-task implementation plan
Five sequential tasks, one commit each, all mechanical:
  1. git mv the three doc files
  2. add scope headers + Next: footers to the eight tour docs
     (also trim README architecture stub)
  3. fix incoming links to old paths
  4. update CLAUDE.md table + add 3 discipline rules
  5. verification gate

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 15:27:45 -04:00
adlee-was-taken
3209bfb410 docs(spec): doc-structure redesign — tour-shaped, topic-named, scope-pinned
Proposes renaming the three overlapping ARCHITECTURE.md files into
topic-named docs (top-level → DESIGN.md, docs/ARCHITECTURE.md →
docs/CRYPTO.md), moving FORMATS.md into docs/, and adding scope
headers + "Next:" footers to every tour doc so the reading order is
canonical: README → DESIGN → CRYPTO → FORMATS → SECURITY →
per-crate ARCHITECTURE → extension/ARCHITECTURE.

Direct response to the drift audit run earlier today (the audit's
content fixes already landed in 210232d, cf7478d, fa659eb). This
spec attacks the structural causes: name collisions, no scope
boundaries, no reading-order signposts, root/docs/ asymmetry.

Migration is mechanical — 5 sequential commits, no content rewrites:
rename, headers+footers, link-fixes, CLAUDE.md update, verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:10:06 -04:00
adlee-was-taken
fa659eb390 docs: sync STATUS / ROADMAP / extension ARCHITECTURE with shipped work
Punch items from doc audit:
- STATUS: "in progress" section was carrying ghost items (vault
  container max-width, README name fix) with no matching commits or
  working-tree changes; trimmed to the one real in-flight item.
- STATUS + ROADMAP: trash/history/devices/settings management-surfaces
  revamp shipped 2026-05-24..05-30 (commits c943a06..88d7228) but was
  still listed as "up next" / medium-term; moved to shipped with
  per-commit SHAs.
- STATUS: v0.5.0 was described as the current tag, but only v0.2.0 and
  four plan-1* tags exist; rephrased as "v0.5.0 train on main, untagged".
- ROADMAP: "Vault lock screen + container polish (in progress)"
  collapsed to just the lock-screen logo (the only real in-flight item).
- extension/ARCHITECTURE: module map missing four shipped components —
  popup/components/form-header.ts, popup/components/settings-security.ts,
  vault/components/backup-panel.ts (#backup route),
  vault/components/import-panel.ts (#import route); all added with
  matching #backup / #import route entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:25:02 -04:00
adlee-was-taken
cf7478d178 docs: refresh per-crate ARCHITECTURE — missing core modules + CLI commands
Punch items from doc audit:
- relicario-core: module map missing 5 public modules (backup,
  device, import_lastpass, recovery_qr, tar_safe); added with
  1-2 sentence descriptions in the existing voice.
- relicario-core: "ed25519-dalek is a dependency placeholder" was
  stale — device.rs now consumes it for signing/verify/keypair.
- relicario-cli: Rate (zxcvbn scoring) and RecoveryQr (generate/unwrap)
  commands were absent from Key flows; added.
- relicario-cli: "Backup-passphrase-style commands (none yet)" rewritten
  — Backup (export/restore .relbak) and Import (lastpass) both shipped.
- relicario-cli: module map refreshed — handlers moved out of main.rs
  into commands/, plus prompt.rs/parse.rs/device.rs/gitea.rs surfaced.

Stale main.rs:NNNN line citations on individual flows are not fixed
here — those handlers now live in commands/*.rs and warrant a deeper
pass later.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:24:49 -04:00
adlee-was-taken
210232d156 docs: fix crypto/format drift — version byte 0x02, AttachmentId 32 hex, DCT 5-50
Punch items from doc audit:
- docs/ARCHITECTURE.md: encrypted file format diagram said version byte
  0x01; actual VERSION_BYTE is 0x02 (crypto.rs:59) and 0x01 is rejected
  with UnsupportedFormatVersion.
- docs/ARCHITECTURE.md: DCT embedding diagram said "Repeat secret 20+
  times" and "positions 4-15"; actual is MIN_COPIES (5) to 50 copies
  chosen by capacity, embedded in zig-zag positions 6-17
  (imgsecret.rs:78, 99-104, 530-537).
- FORMATS.md: AttachmentId table said 16 hex chars / 8 bytes; actual is
  32 hex chars / first 16 bytes of SHA-256 (ids.rs:59-69).
- FORMATS.md: ManifestEntry schema missing r#type field; updated to list
  all ten fields in declared order with serde decorations noted
  (manifest.rs:21-38).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:24:40 -04:00
adlee-was-taken
74a520bada docs: STATUS + extension ARCHITECTURE update for management-surfaces revamp 2026-05-30 13:00:58 -04:00
adlee-was-taken
88d7228570 feat(extension): wire history sidebar slot + #history/<id> route normalization 2026-05-30 13:00:58 -04:00
adlee-was-taken
32e1632c42 feat(extension): add item-history-index pane (lists items with field history) 2026-05-30 13:00:58 -04:00
adlee-was-taken
32e674eb40 feat(extension): field-history pane visual polish — section headers + glyph buttons 2026-05-30 13:00:58 -04:00
adlee-was-taken
ed6e21806f feat(extension): trash pane revamp — per-item purge countdown + glyph restore 2026-05-30 13:00:58 -04:00
adlee-was-taken
047df6eb72 feat(extension): devices pane revamp — fingerprint + added-by + inline two-step revoke 2026-05-30 13:00:58 -04:00
adlee-was-taken
299e7db1ab feat(extension): settings pane revamp — synced/local split + session timeout UI 2026-05-30 13:00:58 -04:00
adlee-was-taken
1edfa67a51 feat(extension): add SSH SHA256 fingerprint util (webcrypto) 2026-05-30 13:00:58 -04:00
adlee-was-taken
367adcedc6 feat(extension): add shared section-header/glyph-btn/kv-row/fingerprint CSS
Add four utility classes to both vault.css and popup styles.css for use in
settings/devices/trash/history management surfaces. These provide standardized
styling for section headers, glyph buttons, key-value rows, and fingerprints
that will be used across all revamped panes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:00:58 -04:00
adlee-was-taken
a587965528 refactor(extension): consolidate 5 relativeTime copies into shared util 2026-05-30 13:00:58 -04:00
adlee-was-taken
9da45dd478 feat(extension): add shared relative-time util with tests 2026-05-30 13:00:58 -04:00
adlee-was-taken
c943a06918 feat(extension): add history/revoke/restore glyph constants 2026-05-30 13:00:58 -04:00
adlee-was-taken
30816c2fe3 docs: implementation plan for vault-tab management surfaces revamp
12 tasks covering settings/devices/trash/history pane revamps, plus
groundwork (glyph constants, relative-time util, ssh-fingerprint util,
shared CSS classes) and routing/nav wiring. Tasks are TDD where the
work is testable (utils) and bite-sized manual-smoke where it's UI.

Spec corrections folded in:
- Devices revoke is upgrade (text+confirm → glyph+inline), not greenfield
- Fingerprint via webcrypto in extension, no SW shape change, no WASM
- Routing keeps 'field-history' as internal dispatch key; only user-facing
  hash normalizes #field-history → #history for backward compat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:01:01 -04:00
adlee-was-taken
1c9fa1e343 docs: add vault-tab management surfaces revamp spec
Brainstormed design covering UX revamp of all four in-vault admin
panes (Settings, Devices, Trash, History) to match the fullscreen
visual language. Closes functional gaps along the way: per-device
session-timeout UI, revoke button surfacing, SHA256 fingerprint +
added-by display, per-item purge countdown, and a new history
index pane.

Item history uses option A (aggregate existing field_history per
item) — no new core storage, no schema change. Ships in v0.5.x
inside the current vault.ts shell; Phase 3 shell rearchitecture and
Phase 4 command palette deferred to their own rounds.

Roadmap entry reconciled to point at the spec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 13:19:46 -04:00
adlee-was-taken
2de250a41e docs: promote overview.md to root ARCHITECTURE.md + add update discipline table
Move docs/architecture/overview.md to ARCHITECTURE.md at the repo root —
it is the primary cross-codebase architecture doc (four-codebase diagram,
inter-codebase contracts, secrets map, build matrix, test strategy, where-to-look
table) and belongs at the root alongside STATUS.md, ROADMAP.md, etc.

Update relative paths inside the file (../../crates/ → crates/, etc.).
Update CHANGELOG.md's one active reference to the old path.

Add a "Living docs — update discipline" table to CLAUDE.md that maps every
ALLCAPS.md file to the area it covers and the trigger for updating it. This
closes the loop on the ALLCAPS.md documentation system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:44:30 -04:00
adlee-was-taken
1758edd5c8 docs: add STATUS/ROADMAP/FORMATS and update CLAUDE.md planning guidance
Introduce three new ALLCAPS.md tracking files:
- STATUS.md: living doc of in-flight work and what shipped in v0.5.0
- ROADMAP.md: full roadmap extracted from CLAUDE.md + expanded with all specced work
- FORMATS.md: wire-format quick-reference (.enc blobs, params.json, devices.json, etc.)

Update CLAUDE.md to replace the single-spec "Design spec" section with a
"Planning & design specs" section that instructs checking docs/superpowers/specs/
and docs/superpowers/plans/ before any planning or implementation work.
Also add the rule to update STATUS.md after every dev iteration, and replace
the stale v0.5.0-in-progress roadmap paragraph with references to the new files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:44:30 -04:00
43 changed files with 4727 additions and 705 deletions

View File

@@ -1,5 +1,146 @@
# Changelog
## v0.6.0 — 2026-05-30
Rolls up four weeks of post-v0.5.0 work into one tag: the Phase 2B
polish foundation, the v0.5.1 train (Streams A/B/C — 3-column vault
layout, left-nav settings, Recovery QR), the 1C-γ slice (Document
type, attachments, device registration from popup, trash & history
UI), the Plan B multi-stream refactor (Cycles 1+2), the vault-tab
management surfaces revamp, and the doc-structure redesign. The
in-flight scope outgrew the original v0.5.1 plan, so this cuts as a
minor bump.
### Added
- **Recovery QR — 1-of-2 disaster-recovery path.** `image_secret` is
encrypted under an Argon2id-derived key from the passphrase, packed
into a 109-byte binary payload (magic `RREC` + version 0x01 + salt
+ nonce + AEAD ciphertext), and rendered as a QR code that is never
written to disk. Surfaces:
- Rust core: `relicario-core/src/recovery_qr.rs``generate_recovery_qr` /
`unwrap_recovery_qr` / `recovery_qr_to_svg`. Production KDF
params (`m=64MiB, t=3, p=4`) live behind a private-fields type so
they cannot drift.
- WASM: `generate_recovery_qr` / `unwrap_recovery_qr` exported; the
session now stashes `image_secret` so the QR can be regenerated
without re-running steganography extraction.
- CLI: `relicario recovery-qr generate` (TTY render) and
`relicario recovery-qr unwrap` subcommands.
- Extension: three-state Security settings card (no QR → amber
warning; QR exists → green status + show/regenerate; explicit
view → modal with print).
- Setup wizard: skippable "generate before you go" banner on the
final step.
- **Document item type.** New typed item for storing a signed document
with a primary attachment. Form takes signature + signed-on date;
detail view renders a signature-block layout. Wired into the popup
add/view/edit dispatchers. Refuses to drop its primary attachment
(use `purge` instead).
- **Attachments end-to-end.** Service worker uploads attachments via
the GitHost putBlob path (GitHub + Gitea Git Data API with fallback);
popup attachments-disclosure component handles add/remove/download
inside all six item-type forms; `📎` indicator shows on item-list
rows that have attachments. Per-vault attachment bytes cap is
enforced both at attach-time and during backup restore.
- **Device registration from the popup.** "Register this device"
triggers an inline name input + WASM keypair generation + persisted
device entry — no setup-wizard detour.
- **Trash + field-history UI.** Trash view shows per-item purge
countdown with restore / per-item purge / empty-all actions.
Field-history view groups changes per field with reveal/copy
glyph buttons. New top-level item-history-index pane lists every
item that has captured history. `#history/<id>` route normalizes
the legacy `#field-history/<id>` URL form.
- **3-column fullscreen vault tab.** Sidebar (200px, type-category
nav) + list (flex) + detail drawer (440px, slides in on row click).
Below 720px the drawer pushes the list full-pane. Bottom sheet for
"new item" type picker uses a pane-only scrim so the sidebar stays
interactive.
- **Left-nav settings page.** Replaces the flat settings dump.
Sections grouped Device (Autofill, Display — password coloring)
vs Vault (Security — Recovery QR + trusted devices, Generator,
Retention, Backup, Import). The standalone Devices sidebar entry
is subsumed into Security.
- **Two-column login form in fullscreen.** Identity (title / URL /
group) and Credentials (username / password / TOTP) render as
side-by-side glass cards above 720px viewport; single-column at
narrow widths. Notes / custom sections / attachments stay full-width
below the grid. Sticky save bar at the bottom of the form pane;
header shows title + dirty subtitle ("unsaved · esc to cancel" or
"no changes") + platform-aware save hint (⌘+S / Ctrl+S).
- **Polish vocabulary.** Patina gold palette tokens
(`--gold-base` `#a88a4a` replacing the brighter `#d2ab43`),
`.surface-backdrop` (subtle radial top-glow + 18px grid texture)
applied to popup body / setup body / vault body, `.glass` card
class with `backdrop-filter: blur(8px)`, `.btn-primary` /
`.btn-secondary` button hierarchy, and `GLYPH_NEXT = '▸'` replacing
ASCII `→` in next/continue buttons.
- **Vault lock-screen logo.** `<img class="brand-logo">` added to the
lock-screen render for parity with the popup unlock view and the
setup wizard.
- **Setup wizard Style C.** Centered hero card + colored progress
track + glyph mode icons, replacing the prior vertical glass-card
wizard.
- **Toast notification system.** Shared `showToast(message, type,
durationMs)` at `extension/src/shared/toast.ts`. Used for sync
success/failure, copy confirmation, device registration result.
Replaces the ad-hoc `sync-status` div.
- **Empty-state treatments.** Popup item list (vault empty / search
returns nothing), vault list (section empty) — each gets a centered
glyph + headline + hint.
- **Per-type glyph icons in popup item rows.** `◉ login`, `
secure_note`, `⊡ totp`, `▭ card`, `⌬ identity`, `⊹ key`,
`≡ document`.
### Changed
- **Vault-tab management surfaces revamp (2026-05-24..05-30).**
Settings pane splits synced (cross-device via Chrome storage) from
local (per-browser) controls and gains a session-timeout UI.
Devices pane shows SHA-256 fingerprint + added-by display + inline
two-step revoke confirm via glyph button. Trash pane shows per-item
purge countdown via `daysUntilPurge`. Field-history pane gets
section headers and reveal/copy glyph buttons. New shared
utilities: `relative-time.ts` (consolidating five duplicate inline
copies), webcrypto `ssh-fingerprint.ts`, shared
section-header / glyph-btn / kv-row / fingerprint CSS.
- **Emoji sweep.** Every remaining UI emoji replaced with a
monochrome glyph constant from `shared/glyphs.ts`. The pop-out
button is now `` (U+29C9, `GLYPH_VAULT_TAB`) instead of `&#x2934;`.
- **License switched to GPL-3.0-or-later.** Was MIT for the early
prototype phase. License headers + `AUTHORS` + crate `Cargo.toml`
authors updated.
- **AttachmentId expanded to 128 bits with `is_valid` check.**
Backup restore now validates IDs (audit I2 / B4).
- **Per-vault attachment bytes cap enforced.** Both CLI attach and
backup restore (audit I3).
### Internal
- **Plan B multi-stream refactor (Cycles 1+2).** CLI `main.rs` split
into per-command modules under `crates/relicario-cli/src/commands/`
with a shared `git_run` helper. New `prompt_or_flag<T>` and
`prompt_or_flag_optional<T>` helpers compress all the `build_*_item`
helpers. `Vault::after_manifest_change` wrapper plus a single
canonical `ParamsFile` in the session avoid duplicated file-system
rebuilds. Core/WASM seam: `base32_decode_lenient`,
`parse_month_year`, `guess_mime` exported from WASM; CLI parsers
migrated to `relicario-core::parse`. Extracted `base32` module
from core, deduplicated two RFC-4648 implementations.
- **Doc-structure redesign (2026-05-30).** Renamed `ARCHITECTURE.md`
→ `DESIGN.md`, `docs/ARCHITECTURE.md` → `docs/CRYPTO.md`,
`FORMATS.md` → `docs/FORMATS.md`. Added scope headers and
"Next:" footers to all tour docs so the reading order is canonical.
`CLAUDE.md` gains a living-docs table and four discipline rules
(scope-boundary check, code-constant pinning, new-doc rule,
plan-state hygiene).
- **CLI quality-of-life.** `gen` alias for `generate`, `-l`/`-w`
short flags, batched purge in `cmd_purge` and `cmd_trash_empty`.
- **Workspace audit cycle.** Stale local branches and worktrees
pruned. Several plan files moved into `docs/superpowers/audits/`
for the record.
## v0.5.0 — 2026-05-02
Three release trains roll into one tag — backup/restore + LastPass
@@ -135,12 +276,12 @@ two confirmed bugs).
the `.form-grid` cards above. Removes the visual rhythm break at the
2-col → full-width transition. The popup surface is unchanged.
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
`docs/architecture/overview.md` now describes four codebases (the
`DESIGN.md` now describes four codebases (the
`relicario-server` pre-receive hook crate is no longer invisible);
`CLAUDE.md` project tree and roadmap reflect current state;
`docs/SECURITY.md` names the server crate and its `verify-commit` /
`generate-hook` subcommands and notes the without-the-hook-it's-
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
advisory caveat; `docs/CRYPTO.md` shows `settings.enc` as a
parallel artifact in the vault-creation flow; the foundational
design spec gains a "historical" status banner pointing readers at
the current docs.

View File

@@ -86,10 +86,46 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
## Design spec
## Planning & design specs
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-relicario-design.md`
**Before starting any planning or implementation task**, search `docs/superpowers/specs/` for a spec covering the feature area, and `docs/superpowers/plans/` for any existing implementation plan. The specs are the authoritative design record; plans track per-milestone implementation details.
## Roadmap
Core references (read before touching crypto, data model, or architecture):
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — threat model, entropy analysis, crypto pipeline, crate layout
- `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — typed-item data model and envelope
- `docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md` — fullscreen UX phase plan
Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.
After completing any dev iteration, update `STATUS.md` to reflect what shipped and what's now in flight. Update the component doc for any area you changed (see table below).
## Roadmap & status
Current in-flight work: `STATUS.md`. Full roadmap with release targets: `ROADMAP.md`. Wire format reference: `docs/FORMATS.md`.
## Living docs — update discipline
| File | What it documents | Update when... |
|---|---|---|
| `DESIGN.md` | Cross-codebase structure: four codebases, contracts, secrets map, build matrix, test strategy | Adding a codebase, changing inter-codebase contracts, new build targets |
| `docs/CRYPTO.md` | Crypto pipeline diagrams, vault creation/unlock flows, DCT embedding, encrypted file format | Changing crypto primitives, format version byte, or file format |
| `crates/relicario-core/ARCHITECTURE.md` | Module map, invariants, key flows, test architecture for `relicario-core` | Adding/changing modules, item types, or crypto invariants in core |
| `crates/relicario-cli/ARCHITECTURE.md` | Module map, invariants, key flows (init, unlock, all commands) for `relicario-cli` | Adding/changing CLI commands, helpers, or session behavior |
| `extension/ARCHITECTURE.md` | Bundle structure, SW↔popup contract, component architecture | Adding bundles, changing the SW message protocol, or major UI flows |
| `docs/SECURITY.md` | Threat model, device auth, env-var trust surface | Adding env vars, changing auth model, new security-relevant config |
| `docs/FORMATS.md` | Wire formats: `.enc` blobs, `params.json`, `devices.json`, manifest schema | Changing any serialized format, version number, or on-disk layout |
| `STATUS.md` | In-flight work, recent landings, what's next | End of every dev iteration |
| `ROADMAP.md` | Full roadmap with release targets | When milestones shift or new work is scoped |
| `CHANGELOG.md` | User-facing release history | When tagging a release |
### Discipline rules
Four rules to prevent the kind of drift the 2026-05-30 audits found:
1. **Scope-boundary check.** When editing a tour doc, verify the change fits the doc's scope header. If it doesn't, the change belongs in a different doc — move it instead of stretching the scope. Concretely: a sentence about crypto added to `DESIGN.md` belongs in `docs/CRYPTO.md`; a wire-format table added to `docs/CRYPTO.md` belongs in `docs/FORMATS.md`.
2. **Code-constant pinning.** When a tour doc cites a code constant (`VERSION_BYTE = 0x02`, `QUANT_STEP = 50.0`, `MIN_COPIES = 5`, `MANIFEST_SCHEMA_VERSION = 2`, etc.), the doc must cite the source file + line. When the underlying constant changes, grep for the citation pattern and update the docs together with the code change in the same commit. Most drift the audit found was code-constant drift — this rule attacks it at the source.
3. **New-doc rule.** When adding a tour doc, also update (a) `DESIGN.md`'s code-map, (b) the reading-order sequence (the "Next:" footer chain), and (c) the living-docs table above. A new doc that doesn't appear in all three is not done.
4. **Plan-state hygiene.** Plan checkboxes and `STATUS.md`/`ROADMAP.md` must reflect what's actually shipped. Two halves:
- **Ship side:** when a commit lands work that maps to a plan task, tick that plan's checkboxes in the same commit (or the immediately-following docs commit). Same for `STATUS.md` — the "Up next" list does not get to lag the actual state of `main` by weeks.
- **Execute side:** before starting execution of a plan whose checkboxes are all unchecked, spot-check git log (`git log --oneline --all --grep <distinctive-name>`) or grep for a distinctive symbol/file the plan would create. A plan whose work already merged is the worst kind of plan to re-execute. The 2026-05-30 status-audit found Phase 2B, v0.5.1 Streams A/B/C, and 1C-γ all stealth-shipped two-to-three weeks earlier because nobody ran this check.

6
Cargo.lock generated
View File

@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relicario-cli"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"arboard",
@@ -2185,7 +2185,7 @@ dependencies = [
[[package]]
name = "relicario-core"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"argon2",
"base64",
@@ -2231,7 +2231,7 @@ dependencies = [
[[package]]
name = "relicario-wasm"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64",
"ed25519-dalek",

View File

@@ -1,12 +1,14 @@
# Architecture overview — Relicario
> **Audience:** anyone wanting to understand the system at the cross-codebase level. This doc owns the four-codebase map, inter-codebase contracts, the secrets map (what secret lives where), the build matrix, and the global code-map index. **Does NOT own:** crypto pipeline details (see [docs/CRYPTO.md](docs/CRYPTO.md)), wire formats (see [docs/FORMATS.md](docs/FORMATS.md)), threat model (see [docs/SECURITY.md](docs/SECURITY.md)), per-crate module maps (see [crates/relicario-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md), [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md), and [extension/ARCHITECTURE.md](extension/ARCHITECTURE.md)).
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
>
> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md)
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md)
> - [extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md)
> - [crates/relicario-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md)
> - [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md)
> - [extension/ARCHITECTURE.md](extension/ARCHITECTURE.md)
>
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
@@ -196,10 +198,10 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
| If you're working on... | Start with |
|---|---|
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/ARCHITECTURE.md) |
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) |
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](crates/relicario-core/ARCHITECTURE.md) |
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](crates/relicario-cli/ARCHITECTURE.md) |
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](extension/ARCHITECTURE.md) |
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](extension/ARCHITECTURE.md) |
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
@@ -211,3 +213,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
## Stale spec docs
The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts).
---
**Next:** [docs/CRYPTO.md](docs/CRYPTO.md) — the crypto pipeline that backs this design.

View File

@@ -4,6 +4,8 @@
# Relicario
> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md).
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
The server only ever sees opaque ciphertext. There is nothing else going on. This README is the security proof.
@@ -135,34 +137,9 @@ Recommended practice: print the QR, store it offline (safe, deposit box), and fo
## Architecture
```
relicario/
├── crates/
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
│ │ ├── item.rs # Item, Field, Manifest data model (serde)
│ │ ├── item_types/ # Per-type cores (Login, SecureNote, Card, Identity, Key, Document, Totp)
│ │ ├── attachment.rs # Encrypted attachment helpers (content-addressed)
│ │ ├── settings.rs # VaultSettings (retention, generator defaults, caps)
│ │ ├── backup.rs # `.relbak` encrypted-backup envelope
│ │ ├── device.rs # ed25519 device keys + revocation entries
│ │ ├── recovery_qr.rs # Paper-printable image_secret backup (XChaCha20-Poly1305 + Argon2id)
│ │ ├── import_lastpass.rs # LastPass CSV → typed items
│ │ └── vault.rs # Encrypt/decrypt items, manifest, settings
│ ├── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
│ ├── relicario-wasm/ # Thin wasm-bindgen wrapper for the browser extension
│ └── relicario-server/ # Pre-receive hook: device-signature verification
├── extension/ # Chrome MV3 / Firefox WebExtension (TypeScript)
└── docs/
├── ARCHITECTURE.md # System overview + flow diagrams
├── SECURITY.md # Manifest integrity model + threat notes
├── architecture/ # Cross-codebase + per-codebase architecture docs
└── superpowers/
└── specs/ # Design specifications with full threat model
```
A short tour of the four codebases and how they fit together lives in [DESIGN.md](DESIGN.md). Crypto pipeline diagrams are in [docs/CRYPTO.md](docs/CRYPTO.md); the wire format reference is [docs/FORMATS.md](docs/FORMATS.md); the threat model is [docs/SECURITY.md](docs/SECURITY.md).
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
`relicario-core` is the platform-agnostic bytes-in/bytes-out heart — no filesystem, no network. The CLI binary and the browser-extension WASM bridge both consume it. See per-codebase deep-dives in `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md`.
### Crypto primitives
@@ -248,3 +225,7 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
---
Built by [Aaron D. Lee](https://adlee.work). Design spec and threat model in `docs/superpowers/specs/`.
---
**Next:** [DESIGN.md](DESIGN.md) — the system tour.

42
ROADMAP.md Normal file
View File

@@ -0,0 +1,42 @@
# Relicario Roadmap
> Living document — update alongside `STATUS.md` when milestones shift.
> "Up next" items have specs; "Medium-term" items may have specs; "Long-term" items are direction, not committed scope.
## Shipped
| Version | Highlights |
|---|---|
| v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/<id>` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license |
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train commit list.
## Up next
All three are specced but have no implementation plan yet. Writing a plan is the first move on any of them.
- **CLI restructure** — subcommand reorganization, interactive TUI mode
Spec: `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
- **Extension restructure** — bundle / message-routing cleanup
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
- **Security polish** — follow-up hardening from the architecture review
Spec: `docs/superpowers/specs/2026-05-04-security-polish-design.md`
## Medium-term
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
## Long-term / backlog
- **Relay server** — encrypted WebSocket relay for multi-device sync without a shared git server
Spec: `docs/superpowers/specs/2026-05-02-relay-server-design.md`
Plan: `docs/superpowers/plans/2026-05-02-relay-server.md` (`c0921b1`)
Code skeleton: `crates/relicario-server/` exists but only houses the pre-receive hook today; the relay binary would either extend or replace it.
- **Mobile** — Rust core compiles to ARM; JNI wrapper for Android, Swift wrapper for iOS
## Non-goals (explicitly deferred or cancelled)
- **Reference-image rotation** — changing the image factor without re-embedding. Back-burner, not cancelled.
- **Per-entry subkeys** — no real-world benefit at family-vault scale; see design rationale in `docs/CRYPTO.md`.
- **libgit2 / gitoxide** — shell-out to `git` is intentional; see `crates/relicario-cli/ARCHITECTURE.md`.

133
STATUS.md Normal file
View File

@@ -0,0 +1,133 @@
# Relicario — Project Status
> Update this file at the end of every dev iteration. It is the single source of truth for what is done, in progress, and next.
## Version
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
**Active track:** picking the next initiative (CLI restructure / extension restructure / security polish all have specs, no plans yet)
## What landed on main since the v0.5.0 version bump
### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`)
Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md`
Plan: `docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md`
- Patina gold palette tokens (`--gold-base` `#a88a4a`, `--gold-mid`, `--gold-shadow`, etc.) replacing the bright amber `#d2ab43`
- `.surface-backdrop` (radial top-glow + 18px grid texture) on popup body, setup body, vault body
- `.glass` card class with `backdrop-filter: blur(8px)` for unlock card, setup steps, form columns
- `.btn-primary` / `.btn-secondary` button hierarchy alongside existing `.btn`
- `GLYPH_NEXT = '▸'` (U+25B8) replacing ASCII `→` in next/continue buttons
- Unlock view restructure: logo-lockup (logo + brand + tagline) + glass card + primary "unlock vault" button + secondary open-vault/settings demoted
- Setup wizard: backdrop + glass step cards + glass mode-picker cards + ▸ on next buttons
- Two-column login form (`surface: 'popup' | 'fullscreen'` flag on `renderForm`)
- Sticky save bar in fullscreen forms with `externalActions` flag
- Form header with title + dirty-state subtitle + platform-aware save hint (⌘+S / Ctrl+S)
### v0.5.1 Stream A — fullscreen + popup layout polish (merged 2026-05-03, `c16adc4`)
- 3-column vault tab: sidebar (200px) + list (flex) + detail drawer (440px)
- Sidebar type-category nav replacing flat item list (All items + per-type counts)
- Bottom sheet for "new item" type picker (pane-only scrim, sidebar stays interactive)
- Shared toast system at `extension/src/shared/toast.ts` (`showToast(message, type, durationMs)`)
- `GLYPH_VAULT_TAB = '⧉'` (U+29C9) replacing `&#x2934;` pop-out button in popup
- Per-type glyph icons in popup item rows
- Empty-state treatments (popup list empty, popup search-empty, vault list section-empty)
- Emoji sweep — all remaining UI emoji replaced with monochrome glyph constants
### v0.5.1 Stream B — settings UX redesign (merged 2026-05-03, `bd6a301`)
- Unified left-nav settings page (Device / Vault grouping)
- Sections: Autofill (Device), Display (Device — password coloring), Security (Vault — Recovery QR + trusted devices), Generator (Vault), Retention (Vault), Backup (Vault), Import (Vault)
- `devices` standalone sidebar entry subsumed into Security section
### v0.5.1 Stream C — Recovery QR (merged 2026-05-03, `934dfe0`)
Spec: `docs/superpowers/specs/2026-05-01-recovery-qr-design.md`
Plan: `docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md`
- Rust core: `relicario-core/src/recovery_qr.rs``generate_recovery_qr` / `unwrap_recovery_qr` / `recovery_qr_to_svg` (109-byte binary payload, never written to disk)
- WASM bindings: `generate_recovery_qr` / `unwrap_recovery_qr` + session stores `image_secret` for regeneration
- CLI: `relicario recovery-qr generate` / `recovery-qr unwrap` subcommands (TTY render)
- Extension: three-state Security settings card; setup wizard "generate before you go" banner
- Setup wizard Style C redesign — centered hero card + colored progress track + glyph mode icons (replacing the prior glass-card vertical wizard)
### 1C-γ — attachments + Document type + device registration + trash + history
Specs: `docs/superpowers/specs/2026-04-24-relicario-extension-1c-gamma1-design.md`, `docs/superpowers/specs/2026-04-26-relicario-extension-1c-gamma2-design.md`
Plans: `docs/superpowers/plans/2026-04-24-relicario-extension-1c-gamma1.md`, `docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md`
- Core: `relicario-core/src/item_types/document.rs` (DocumentCore — signature + signed-on date)
- Extension: Document type form + signature-block detail (`extension/src/popup/components/types/document.ts`)
- Attachments wired into 6 type forms via shared disclosure; 📎 indicator in item list
- Attachment cap setting (per-vault bytes cap) in vault settings; CLI enforces cap on attach
- Service worker: trash operations (listTrashed, restoreItem, purgeItem, purgeAllTrash); batched purge
- Device registration from the popup (no setup-wizard detour)
- Field history end-to-end (WASM `get_field_history`, popup viewer)
- Attachment IDs expanded to 128 bits with `is_valid` check (audit I2)
- Per-vault attachment bytes cap enforced (audit I3)
- IDs validated on backup restore (audit B4)
### Plan B multi-stream refactor (2026-05-09 → 2026-05-25)
Cycle 1:
- Stream A: security audit fixes + docs polish (`89090a8`)
- Stream B: `main.rs` split into `commands/` modules + `git_run` helper (`b9bd152`)
Cycle 2:
- Stream A: `prompt_or_flag<T>` + builder compression — compressed `build_*_item` helpers (`3dd1e1b`)
- Stream B: `Vault::after_manifest_change` wrapper, single canonical `ParamsFile` in session (`3759f6a`)
- Stream C: core/WASM seam — `base32_decode_lenient`, `parse_month_year`, `guess_mime` exported from WASM; CLI parsers migrated to `relicario-core::parse` (`e69b347`)
Misc:
- CLI: `gen` alias for `generate`, `-l`/`-w` short flags, batched purge
- `base32` module extracted from core, two duplicate RFC-4648 impls deduplicated
- License switched to GPL-3.0-or-later
### Vault-tab management surfaces revamp (2026-05-24 → 2026-05-30)
Spec: `docs/superpowers/specs/2026-05-23-vault-tab-management-surfaces-revamp-design.md`
Plan: `docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md`
- Shared utilities: `relative-time.ts` consolidating 5 duplicate inline copies (`9da45dd`, `a587965`), webcrypto `ssh-fingerprint.ts` (`1edfa67`), shared section-header / glyph-btn / kv-row / fingerprint CSS (`367adce`), history/revoke/restore glyph constants (`c943a06`)
- Settings pane revamp — synced/local split + session timeout UI (`299e7db`)
- Devices pane revamp — SHA256 fingerprint + added-by display + glyph revoke with inline two-step confirm (`047df6e`)
- Trash pane revamp — per-item purge countdown via `daysUntilPurge` + glyph restore + bottom-right empty-trash (`ed6e218`)
- Field-history pane visual polish — section headers + glyph reveal/copy buttons (`32e674e`)
- Item-history-index pane — top-level "items with history" list (`32e1632`)
- Sidebar slot wiring + `#history/<id>` route with `#field-history/<id>` legacy normalization (`88d7228`)
### Doc-structure redesign (2026-05-30, complete)
Spec: `docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md`
Plan: `docs/superpowers/plans/2026-05-30-doc-structure-redesign.md` (all 37 sub-step boxes ticked)
- Task 1: Renamed `ARCHITECTURE.md``DESIGN.md`, `docs/ARCHITECTURE.md``docs/CRYPTO.md`, `FORMATS.md``docs/FORMATS.md` (`36a59cd`)
- Task 2: Added scope headers + "Next:" footers to all tour docs (`5e7023f`)
- Task 3: Fixed incoming links to renamed paths (`01377e7`)
- Task 4: Updated CLAUDE.md living-docs table + added three discipline rules (`bae3f7c`)
- Task 5: Final verification gate — all 6 steps pass cleanly (Step 3 grep had three false positives — correct new-path sibling links inside `docs/`, not stale references)
### Post-audit cleanup (2026-05-30)
- `STATUS.md` + `ROADMAP.md` synced with three weeks of stealth-shipped work (`72a59c6`, `0bde093`)
- CLAUDE.md gains rule #4 (plan-state hygiene) + doc-structure plan checkboxes ticked retroactively (`cccb7d7`)
- Vault lock-screen logo: `<img class="brand-logo">` added to `renderLockScreen` for parity with popup unlock view (`39ae629`)
- Extension test-debt cleared: 17 stale tests (settings + devices + router) updated to match the post-Stream-B + post-revamp components — 371/371 extension + 281 Rust tests green (`797709b`, `c9802ef`, `361f3b4`)
- v0.6.0 cut: version bumps + CHANGELOG entry covering the full v0.5.x train
## In progress (uncommitted on main)
- `.claude/settings.json` — harness config tweaks (kept aside intentionally)
- Two superseded doc-plan/spec files showing modifications — `2026-04-22-relicario-extension-1c-beta1.md` and `2026-04-11-relicario-design.md` (kept aside intentionally)
## Up next
The "Up next" queue at v0.6.0 is the three 2026-05-04 architecture-review specs. Each is specced but has no implementation plan yet — writing a plan is the first move on any of them.
1. **CLI restructure** (spec `2026-05-04-cli-restructure-design.md`) — subcommand reorganization + interactive TUI mode.
2. **Extension restructure** (spec `2026-05-04-extension-restructure-design.md`) — bundle / message-routing cleanup.
3. **Security polish** (spec `2026-05-04-security-polish-design.md`) — follow-up security hardening from the architecture review.
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.5.0` entry, dated 2026-05-02 — predates the v0.5.1 train work and will be revised when the next tag cuts).

View File

@@ -1,5 +1,7 @@
# Architecture: relicario-cli
> **Audience:** contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. **Does NOT own:** crypto, wire formats, or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/FORMATS.md](../../docs/FORMATS.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)).
## What this crate is for
The `relicario` binary is the platform layer for `relicario-core`: it adds
@@ -16,22 +18,46 @@ locally, and lets recovery debugging happen with familiar tooling.
## Module map
The crate is three files of source and a `tests/` directory. Each source file
has one job.
`src/main.rs` is now a thin clap-surface + dispatcher; per-command logic lives
under `src/commands/`. Each source file has one job.
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
handler. Internal structure: a top-level `Cli` / `Commands` enum
(`main.rs:13-275`), a flat dispatcher `match` in `main()`
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
can be tested through the same integration paths. Owns all clap argument
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
`commit_paths` helper that is the single chokepoint for git commits during
vault mutations.
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
Owns the top-level `Cli` / `Commands` enum and every subcommand enum
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
`DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
three test-only env-var hooks (`test_passphrase_override`,
`test_item_secret_override`, `test_backup_passphrase_override`) — each is
stripped from release builds via `#[cfg(debug_assertions)]`.
- **`src/commands/`** — one module per top-level command. `mod.rs` re-exports
the public surface and hosts the shared `commit_paths` helper (the single
chokepoint for git commits during vault mutations) plus other cross-command
glue. Per-command modules: `init`, `add`, `get`, `list` (also hosts
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
`backup` (export / restore), `import` (lastpass), `attach` (attach /
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
builder/editor reads top-to-bottom and can be tested through the same
integration paths.
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
- **`src/device.rs`** — device-management plumbing called by
`commands::device`: ed25519 keypair generation via `relicario-core::device`,
on-disk layout under `<config_dir>/relicario/devices/<name>/`, and the
read/write of `.relicario/devices.json` / `revoked.json`.
- **`src/gitea.rs`** — minimal Gitea REST client used by `commands::device add`
/ `revoke` to register and remove deploy keys. Reads
`RELICARIO_GITEA_{URL,TOKEN,OWNER,REPO}` env vars (overridable via CLI flags).
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
@@ -306,13 +332,65 @@ rewrite `devices.json`, commit `device: revoke <name>`. Note that device
keys are kept entirely separate from the KDF (passphrase × image stays
unchanged across device add/revoke), as per the design spec.
### Backup-passphrase-style commands (none yet)
### Backup (`commands::backup`, `commands/backup.rs`)
The import / export / `import-lastpass` commands described in
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
not yet implemented. When they land they'll fit in the dispatcher
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here
until that work begins.
Two subcommands, both keyed by a *backup* passphrase that is independent of
the vault master passphrase.
- **`backup export <out> [--include-image] [--image PATH] [--no-history]`** —
reads the entire on-disk vault layout (`.relicario/{salt,params.json,
devices.json}`, `manifest.enc`, `settings.enc`, every `items/*.enc`, every
`attachments/<iid>/<aid>.enc`), optionally bundles the reference JPEG and
the `.git/` directory (as an in-memory tar), and hands the lot to
`relicario_core::backup::pack_backup` with a zxcvbn-gated backup
passphrase prompted twice. The resulting `.relbak` is written via
`tmp` + rename. A `.relicario/last_backup` marker file (ISO-8601 line) is
also written so `cmd_status` can show "last backup at …".
- **`backup restore <input> [<target>]`** — refuses to overwrite an existing
vault (`target/.relicario/` must not exist). Unpacks the `.relbak` via
`unpack_backup`, then materialises every byte into the target layout. The
bundled `.git/` tar is extracted via the hardened
`relicario_core::safe_unpack_git_archive` (path-traversal / symlink /
size-cap guards) with a cap of `min(100 × tar_size, 1 GiB)`; if no
history was bundled, the target gets a fresh `git init` + initial commit.
### Import (`commands::import`, `commands/import.rs`)
- **`import lastpass <csv>`** — reads the CSV, calls
`relicario_core::import_lastpass::parse_lastpass_csv`, then unlocks the
vault and writes every produced `Item` through `vault.save_item` + manifest
upsert. Failed rows surface as `ImportWarning`s on stderr and never abort
the import; only a missing or malformed header is fatal. Commit message:
`import: <N> items from LastPass (<csv-filename>)`. The dispatch shape
(`ImportAction` subcommand enum) is in place for future importers
(Bitwarden, 1Password, etc.) — each would add one `ImportAction` variant
and one helper.
### Rate (`commands::rate`, `commands/rate.rs`)
`rate <passphrase|->` runs `relicario_core::generators::rate_passphrase`
(zxcvbn-backed) and prints the 04 score, a human-readable label, and the
estimated guess count as `~10^N`. Reads one line from stdin when the
argument is `-`, which keeps the passphrase out of shell history. Purely
informational — does not unlock or mutate anything; the `init` command
calls `validate_passphrase_strength` directly and does not consult `rate`.
### RecoveryQr (`commands::recovery_qr`, `commands/recovery_qr.rs`)
Two subcommands wrapping `relicario_core::recovery_qr::{generate_recovery_qr,
unwrap_recovery_qr}`.
- **`recovery-qr generate`** — re-extracts the 32-byte image_secret from the
reference JPEG (via `get_image_path` + `imgsecret::extract`), prompts for
the recovery passphrase (which may be the same as the vault passphrase or
different — domain-separated by core), produces the 109-byte sealed
payload, and renders it as a Unicode-block QR (EcLevel::M) directly to
stdout. The payload is **never written to disk** — the user is expected to
print or photograph it.
- **`recovery-qr unwrap`** — reads a base64-encoded payload from stdin,
prompts for the recovery passphrase, runs `unwrap_recovery_qr`, and prints
the recovered `image_secret` as hex. Useful for recovery dry-runs and for
reconstructing a lost reference image.
## Cross-cutting concerns
@@ -537,3 +615,7 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
is why every `cmd_*` that takes a `query: String` (get, edit,
history, rm, restore, purge, attach, attachments, extract, detach)
works the same way.
---
**Next:** [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-cli"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
description = "CLI for relicario password manager"
license = "GPL-3.0-or-later"

View File

@@ -1,5 +1,7 @@
# Architecture: relicario-core
> **Audience:** contributors editing or extending `relicario-core`. This doc owns the module map for this crate, module-level invariants (e.g., no filesystem, no network), key flows at the module level, and the crate's test architecture. **Does NOT own:** crypto primitives or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)), wire formats (see [../../docs/FORMATS.md](../../docs/FORMATS.md)).
## What this crate is for
`relicario-core` is the platform-agnostic cryptographic and data-model heart of the
@@ -101,6 +103,38 @@ Pipeline" and "Crate Layout").
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
Quantization Index Modulation, and crop-recovery extractor. No other module
imports it; it is consumed only via the public re-export from `lib.rs`.
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
bytes (salt, params.json, devices.json, manifest, settings, items,
attachments, optional reference JPEG, optional `.git/` tar) in an
XChaCha20-Poly1305 envelope keyed by Argon2id over a user-chosen *backup*
passphrase. The backup key is independent of any vault master key, and
Argon2id parameters are pinned to the v1 values (m=64MiB, t=3, p=4) so a v1
reader doesn't need to negotiate them.
- **`import_lastpass.rs`** — `parse_lastpass_csv` plus `ImportWarning`. Pure
bytes-in / `Vec<Item>`-out LastPass CSV importer: validates the fixed
8-column header, mints fresh IDs and timestamps for each row, downgrades or
skips malformed rows into `ImportWarning`s instead of aborting the import.
Only fatal error is a missing/malformed header.
- **`device.rs`** — Device-identity surface: `DeviceEntry`, `RevokedEntry`,
`generate_keypair`, `sign`, `verify`, `fingerprint`. ed25519 in OpenSSH
format (so private keys are interchangeable with `ssh-keygen`-produced
keys); the same module backs both `.relicario/devices.json` entries and the
server's pre-receive commit-verification hook.
- **`tar_safe.rs`** — `safe_unpack_git_archive` + `DEFAULT_MAX_UNCOMPRESSED`
(1 GiB). Hardened tar reader used by `backup::unpack_backup` for the
bundled `.git/` directory: rejects `..` components, absolute paths, Windows
drive prefixes, symlinks, hardlinks, and any entry whose declared size
(or running total across all entries) exceeds the supplied cap.
- **`recovery_qr.rs`** — `generate_recovery_qr` / `unwrap_recovery_qr` plus
`recovery_qr_to_svg`. Produces a 109-byte XChaCha20-Poly1305 envelope
around the 32-byte image_secret, keyed by Argon2id over a user-chosen
recovery passphrase with the domain-separation prefix
`b"relicario-recovery-v1\0"`. Parameters are pinned at module scope —
changing them invalidates every printed QR — and both salt and nonce are
freshly randomized per call so two QRs printed from the same inputs are
different bytes.
## Invariants & contracts
@@ -386,11 +420,11 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
`generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
secret; production code is `OsRng` only.
- **`ed25519-dalek` is a dependency placeholder.** Listed in
`Cargo.toml:17` but unused in `src/`. It exists for the future
device-key surface (`RelicarioError::DeviceKey` is the reserved variant,
`error.rs:84-88`); device-key signing currently happens in
`relicario-cli` instead.
- **`ed25519-dalek` is consumed by `device.rs`.** Together with `ssh-key` (for
OpenSSH wire encoding) it backs `generate_keypair`, `sign`, and `verify`
the same primitives the CLI uses to populate `.relicario/devices.json` and
the server uses to verify pre-receive commit signatures. The corresponding
error variant is `RelicarioError::DeviceKey`.
## Test architecture
@@ -512,3 +546,7 @@ round-trip, and the oversized-image-header rejection path.
source in this crate** (`time.rs:6-8`). Tests that need determinism pass an
explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they
do not stub `now_unix`.
---
**Next:** [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-core"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
description = "Core library for relicario password manager"
license = "GPL-3.0-or-later"

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-wasm"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
description = "WASM bindings for relicario password manager"
license = "GPL-3.0-or-later"

View File

@@ -1,4 +1,6 @@
# Relicario — Architecture
# Relicario — Crypto Pipeline
> **Audience:** anyone evaluating or auditing the crypto. This doc owns Argon2id parameters and rationale, XChaCha20-Poly1305 rationale, vault creation/unlock flow diagrams, DCT-steganography embed and extract flows, and the high-level encrypted-file-format diagram. **Does NOT own:** byte-level schemas or JSON shapes (see [FORMATS.md](FORMATS.md)), attacker scenarios (see [SECURITY.md](SECURITY.md)), or per-module crypto implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
## System Overview
@@ -161,11 +163,14 @@ master_key ────────►│ XChaCha20 │──────
│ selected block: │
│ │
│ QIM embed bits │
│ in positions
4-15 (mid-freq)
│ in zig-zag
positions 6-17
│ (mid-frequency) │
│ │
│ Repeat secret │
20+ times
MIN_COPIES (5)
│ to 50 times, │
│ by capacity │
└────────┬─────────┘
@@ -181,6 +186,8 @@ master_key ────────►│ XChaCha20 │──────
carries 256-bit secret)
```
The redundancy count is chosen at embed time based on available DCT capacity: `num_copies = (total_blocks / BLOCKS_PER_COPY).min(50)`, with `BLOCKS_PER_COPY = 22` and a floor of `MIN_COPIES = 5` (`crates/relicario-core/src/imgsecret.rs:78,530-537`). Images that cannot fit at least 5 copies are rejected before embed. Majority voting across these copies at extract time requires ≥ 60 % confidence per bit.
## Extraction (with crop recovery)
```
@@ -214,10 +221,12 @@ Input JPEG (possibly re-encoded or cropped)
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
│ version │ nonce │ ciphertext │ auth tag │
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
│ 0x01 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
│ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
└─────────┴────────────────────────┴──────────────────┴──────────────────┘
```
`VERSION_BYTE = 0x02` (`crates/relicario-core/src/crypto.rs:59`). Blobs starting with any other byte are rejected with `UnsupportedFormatVersion { found, expected: 0x02 }`. The legacy `0x01` format from the pre-typed-items era is no longer supported.
## Crate Architecture
```
@@ -267,3 +276,7 @@ Stolen device: ████░░░░░░░░░░░░░
Both factors compromised: game over (same as every password manager)
```
---
**Next:** [FORMATS.md](FORMATS.md) — the byte-level wire formats.

108
docs/FORMATS.md Normal file
View File

@@ -0,0 +1,108 @@
# Relicario Wire Formats
> **Audience:** anyone implementing a compatible client or reading raw vault bytes. This doc owns the `.enc` blob layout, `params.json` / `salt` / `devices.json` / `revoked.json` shapes, the manifest JSON schema, the `.relbak` envelope, item-ID formats, and the settings JSON schema. **Does NOT own:** why these formats look this way (see [CRYPTO.md](CRYPTO.md)), threat model around them (see [SECURITY.md](SECURITY.md)), or Rust struct internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
> Quick-reference for the load-bearing binary and JSON formats. Check this file before touching serialization, versioning, or storage layout code. Full diagrams and invariants live in the per-crate `ARCHITECTURE.md` files.
## Encrypted blob (`.enc` files)
Every encrypted file — `manifest.enc`, `settings.enc`, `items/<id>.enc`, `attachments/<item-id>/<aid>.enc` — uses the layout produced by `relicario_core::crypto::encrypt` (`crypto.rs`):
```
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
│ version │ nonce │ ciphertext │ auth tag │
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
│ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
└─────────┴────────────────────────┴──────────────────┴──────────────────┘
```
- `VERSION_BYTE = 0x02` (`crypto.rs:59`). Any blob starting with `0x01` is rejected with `UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`.
- Minimum valid blob length: 41 bytes (1 + 24 + 0 + 16).
- Nonces are always fresh from `OsRng` — no caller-supplied nonces.
- Full diagram: `docs/CRYPTO.md` § "Encrypted File Format".
## `.relicario/params.json`
```json
{
"format_version": 2,
"aead": "xchacha20-poly1305",
"salt_path": ".relicario/salt",
"kdf": {
"argon2_m": 65536,
"argon2_t": 3,
"argon2_p": 4
}
}
```
Parsed via `ParamsFile { kdf: KdfParams }` in `session.rs`. The `kdf` nesting is intentional — `format_version`, `aead`, and `salt_path` co-exist for forward-compat probing. Do not flatten. Production defaults: `m=65536` (64 MiB), `t=3`, `p=4`. Tests use `m=256, t=1, p=1`.
## `.relicario/salt`
32 raw bytes. Not secret. Generated once at vault init via `OsRng`. Feeds Argon2id as the KDF salt.
## Manifest (`manifest.enc`)
Decrypts to JSON matching the `Manifest` struct (`manifest.rs`).
- **Schema version:** `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`). v1 manifests (pre-typed-items) fail to parse and are not supported.
- **`ManifestEntry` fields** (declared order in `manifest.rs:21-38`): `id`, `type`, `title`, `tags`, `favorite`, `group`, `icon_hint`, `modified`, `trashed_at`, `attachment_summaries`. The `type` field is `r#type: ItemType` in Rust but serializes as the bare JSON key `"type"` (no serde rename — `r#` is just the raw-identifier escape). `group`, `icon_hint`, and `trashed_at` are `#[serde(skip_serializing_if = "Option::is_none")]`; `tags`, `favorite`, and `attachment_summaries` use `#[serde(default)]`.
- The manifest is rebuilt from scratch on every `upsert` — it can never drift from the source-of-truth item files.
- Supports case-insensitive title/tag search without decrypting any item.
## `.relicario/devices.json`
```json
[
{ "name": "laptop", "public_key": "<hex-encoded ed25519 public key>" }
]
```
An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes accepted). Both `devices.json` and `revoked.json` must be empty for bootstrap mode to activate — a non-empty `revoked.json` alone forces strict verification.
## `.relicario/revoked.json`
```json
[
{ "name": "old-laptop", "public_key": "<hex>", "revoked_at": 1746000000 }
]
```
Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time).
## Item IDs and Field IDs
| Kind | Length | Entropy | Source |
|---|---|---|---|
| `ItemId` | 16 hex chars | 64 bits | `OsRng` |
| `FieldId` | 16 hex chars | 64 bits | `OsRng` |
| `AttachmentId` | 32 hex chars | 128 bits | first 16 bytes (32 hex chars) of `SHA-256` over the plaintext |
`AttachmentId` is content-addressed — identical plaintexts deduplicate in git automatically. The 128-bit truncation (`ids.rs:59-69`) was widened from 64 bits per audit I2/B4 to put birthday-collision risk out of reach.
## `.relbak` backup format
A zstd-compressed tar archive containing a bare git clone of the vault. Designed for `relicario backup export/restore`.
Full spec: `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md`.
## `ItemCore` JSON (internal)
`ItemCore` uses `#[serde(tag = "type")]` — the outer JSON object gets a `"type"` discriminator key. No `*Core` struct may have a field named `"type"` (use `"kind"` instead — see `CardKind`, `TotpKind`).
Full item type inventory: `crates/relicario-core/ARCHITECTURE.md` § "Module map".
## KDF input construction
The password fed to Argon2id is length-prefixed to prevent extension attacks:
```
u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || image_secret
```
NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`.
---
**Next:** [SECURITY.md](SECURITY.md) — the threat model.

View File

@@ -1,5 +1,7 @@
# Relicario Security Model
> **Audience:** auditors and curious users. This doc owns the threat model, attacker-scenarios table, device-authentication model, env-var trust surface, and known limitations. **Does NOT own:** crypto primitive details (see [CRYPTO.md](CRYPTO.md)), wire formats (see [FORMATS.md](FORMATS.md)), or implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) and [../crates/relicario-cli/ARCHITECTURE.md](../crates/relicario-cli/ARCHITECTURE.md)).
## Cryptographic Protection
Relicario uses two-factor vault decryption:
@@ -102,3 +104,7 @@ standard `--release` profile).
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
---
**Next:** [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) — implementation, starting with the platform-agnostic core.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,611 @@
# Doc Structure Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Rename the three overlapping `ARCHITECTURE.md` files into topic-named docs, move `FORMATS.md` into `docs/`, and pin every tour doc with a scope header + a "Next:" footer so the reading order is canonical and the drift surface shrinks.
**Architecture:** Five sequential commits, each mechanical. No content is rewritten — the drift audit already cleaned the content in `210232d`, `cf7478d`, `fa659eb`. This plan only renames files, adds scope headers + "Next:" footers, fixes incoming links to old paths, and updates `CLAUDE.md`'s living-docs table and discipline rules.
**Tech Stack:** Markdown, `git mv` (so blame/history follow), `grep -rn` for link verification, `git log --follow` for rename verification.
**Source spec:** `docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md` — refer back when ambiguity arises.
---
## File Structure
**Renamed (Task 1):**
- `ARCHITECTURE.md``DESIGN.md` (top-level system tour)
- `docs/ARCHITECTURE.md``docs/CRYPTO.md` (crypto pipeline + flows)
- `FORMATS.md``docs/FORMATS.md` (wire formats)
**Modified (Tasks 2-4):**
- `README.md` — trim mid-section "Architecture" stub to a one-paragraph pointer, add header + "Next:" footer.
- `DESIGN.md` — add scope header + "Next:" footer (no content rewrite of the tour itself).
- `docs/CRYPTO.md` — add scope header + "Next:" footer.
- `docs/FORMATS.md` — add scope header + "Next:" footer.
- `docs/SECURITY.md` — add scope header + "Next:" footer.
- `crates/relicario-core/ARCHITECTURE.md` — add scope header + "Next:" footer.
- `crates/relicario-cli/ARCHITECTURE.md` — add scope header + "Next:" footer.
- `extension/ARCHITECTURE.md` — add scope header + "Next:" footer (End of tour).
- `CLAUDE.md` — update the "Living docs — update discipline" table with new filenames; update the "Planning & design specs" core-references list if it references old paths; add three new discipline rules.
- Various callsites in `docs/superpowers/specs/*.md` and the per-crate / extension `ARCHITECTURE.md` files that link to old paths.
**Unchanged:** `STATUS.md`, `ROADMAP.md`, `CHANGELOG.md`, `LICENSE`, `docs/superpowers/{specs,plans,audits,coordination,reviews,test-runs,MULTI-AGENT.md}`.
---
## Task 1: Rename files
**Files:**
- Rename: `ARCHITECTURE.md``DESIGN.md`
- Rename: `docs/ARCHITECTURE.md``docs/CRYPTO.md`
- Rename: `FORMATS.md``docs/FORMATS.md`
- [x] **Step 1: Confirm clean working tree (or only known dirt)**
Run: `git status`
Expected: only pre-existing modifications (`.claude/settings.json`, `docs/superpowers/plans/2026-04-22-relicario-extension-1c-beta1.md`, `docs/superpowers/specs/2026-04-11-relicario-design.md`, `extension/src/vault/vault.ts`). No other unstaged changes. If anything else is modified, stop and ask the user.
- [x] **Step 2: Perform the three renames**
Run:
```bash
git mv ARCHITECTURE.md DESIGN.md
git mv docs/ARCHITECTURE.md docs/CRYPTO.md
git mv FORMATS.md docs/FORMATS.md
```
Expected: no errors. `git status` should now show three renamed files staged.
- [x] **Step 3: Verify renames are tracked as renames, not delete+add**
Run: `git status --short`
Expected output includes three lines starting with `R` (rename), not `D` (delete) + `??` (new):
```
R ARCHITECTURE.md -> DESIGN.md
R docs/ARCHITECTURE.md -> docs/CRYPTO.md
R FORMATS.md -> docs/FORMATS.md
```
If git shows `D` + new file instead, stop and investigate — likely means the file content changed enough that git can't see the rename. (For this commit we changed nothing, so renames should be clean.)
- [x] **Step 4: Commit the renames**
Run:
```bash
git commit -m "$(cat <<'EOF'
docs: rename for doc-structure redesign — DESIGN / CRYPTO / docs/FORMATS
Mechanical renames only; no content changes. Tracked as renames so
git blame / git log --follow survive intact.
- ARCHITECTURE.md → DESIGN.md (top-level system tour)
- docs/ARCHITECTURE.md → docs/CRYPTO.md (crypto pipeline)
- FORMATS.md → docs/FORMATS.md (wire formats; aligns with docs/ layout)
Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
EOF
)"
```
Expected: one commit created. Verify with `git log --oneline -1`.
- [x] **Step 5: Verify history follows the rename**
Run: `git log --follow --oneline DESIGN.md | head -5`
Expected: shows the rename commit on top, then commits to the old `ARCHITECTURE.md` underneath. Same idea for `docs/CRYPTO.md` and `docs/FORMATS.md` (`git log --follow --oneline docs/CRYPTO.md | head -5`).
---
## Task 2: Add scope headers + "Next:" footers + trim README architecture section
**Files (all modified):**
- `README.md`
- `DESIGN.md`
- `docs/CRYPTO.md`
- `docs/FORMATS.md`
- `docs/SECURITY.md`
- `crates/relicario-core/ARCHITECTURE.md`
- `crates/relicario-cli/ARCHITECTURE.md`
- `extension/ARCHITECTURE.md`
Convention: scope header sits as a blockquote *immediately under the H1 title*, separated by a blank line. The "Next:" footer sits as the very last line of the file (with a blank line above it).
- [x] **Step 1: Add scope header + footer to `README.md`**
Read `README.md` and find the existing H1 (`# Relicario` near top). Insert the scope blockquote on the line immediately after the H1's blank-line separator, then add the footer at the very end of the file.
**Header (insert after H1):**
```markdown
> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md).
```
**Footer (append at very end of file):**
```markdown
---
**Next:** [DESIGN.md](DESIGN.md) — the system tour.
```
- [x] **Step 2: Trim README's mid-section "Architecture" stub to a one-paragraph pointer**
In `README.md`, locate the `## Architecture` section (it's the one containing a tree diagram of `relicario/` and references to `docs/architecture/`). Replace the entire section content (from the heading through the end of the tree diagram, but BEFORE the next H2) with:
```markdown
## Architecture
A short tour of the four codebases and how they fit together lives in [DESIGN.md](DESIGN.md). Crypto pipeline diagrams are in [docs/CRYPTO.md](docs/CRYPTO.md); the wire format reference is [docs/FORMATS.md](docs/FORMATS.md); the threat model is [docs/SECURITY.md](docs/SECURITY.md).
`relicario-core` is the platform-agnostic bytes-in/bytes-out heart — no filesystem, no network. The CLI binary and the browser-extension WASM bridge both consume it. See per-codebase deep-dives in `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md`.
```
Do NOT touch the `### Crypto primitives` table or the `### Encrypted file format` block if they come immediately after — those are reader-friendly summaries that belong in the README. Only the codebase tree + the broken `docs/architecture/` reference go.
Verify by reading the README from start to finish to confirm the flow still reads naturally.
- [x] **Step 3: Add scope header + footer to `DESIGN.md`**
Read `DESIGN.md`. Insert this header after its H1 (currently `# Architecture overview — Relicario`):
```markdown
> **Audience:** anyone wanting to understand the system at the cross-codebase level. This doc owns the four-codebase map, inter-codebase contracts, the secrets map (what secret lives where), the build matrix, and the global code-map index. **Does NOT own:** crypto pipeline details (see [docs/CRYPTO.md](docs/CRYPTO.md)), wire formats (see [docs/FORMATS.md](docs/FORMATS.md)), threat model (see [docs/SECURITY.md](docs/SECURITY.md)), per-crate module maps (see [crates/relicario-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md), [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md), and [extension/ARCHITECTURE.md](extension/ARCHITECTURE.md)).
```
Append footer at end of file:
```markdown
---
**Next:** [docs/CRYPTO.md](docs/CRYPTO.md) — the crypto pipeline that backs this design.
```
- [x] **Step 4: Add scope header + footer to `docs/CRYPTO.md`**
Read `docs/CRYPTO.md`. Insert this header after its H1 (currently `# Relicario — Architecture`):
```markdown
> **Audience:** anyone evaluating or auditing the crypto. This doc owns Argon2id parameters and rationale, XChaCha20-Poly1305 rationale, vault creation/unlock flow diagrams, DCT-steganography embed and extract flows, and the high-level encrypted-file-format diagram. **Does NOT own:** byte-level schemas or JSON shapes (see [FORMATS.md](FORMATS.md)), attacker scenarios (see [SECURITY.md](SECURITY.md)), or per-module crypto implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
```
Also update the H1 itself from `# Relicario — Architecture` to `# Relicario — Crypto Pipeline` so the file's title matches its renamed scope.
Append footer at end of file:
```markdown
---
**Next:** [FORMATS.md](FORMATS.md) — the byte-level wire formats.
```
- [x] **Step 5: Add scope header + footer to `docs/FORMATS.md`**
Read `docs/FORMATS.md`. Insert this header after its H1 (currently `# Relicario Wire Formats`):
```markdown
> **Audience:** anyone implementing a compatible client or reading raw vault bytes. This doc owns the `.enc` blob layout, `params.json` / `salt` / `devices.json` / `revoked.json` shapes, the manifest JSON schema, the `.relbak` envelope, item-ID formats, and the settings JSON schema. **Does NOT own:** why these formats look this way (see [CRYPTO.md](CRYPTO.md)), threat model around them (see [SECURITY.md](SECURITY.md)), or Rust struct internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
```
The existing intro blockquote (`> Quick-reference for the load-bearing binary and JSON formats. …`) was a partial scope statement — leave it in place as a useful summary sentence, but the new scope blockquote above it is the canonical one. Place the new blockquote between H1 and the existing quick-reference blockquote.
Append footer at end of file:
```markdown
---
**Next:** [SECURITY.md](SECURITY.md) — the threat model.
```
- [x] **Step 6: Add scope header + footer to `docs/SECURITY.md`**
Read `docs/SECURITY.md`. Insert this header after its H1 (currently `# Relicario Security Model`):
```markdown
> **Audience:** auditors and curious users. This doc owns the threat model, attacker-scenarios table, device-authentication model, env-var trust surface, and known limitations. **Does NOT own:** crypto primitive details (see [CRYPTO.md](CRYPTO.md)), wire formats (see [FORMATS.md](FORMATS.md)), or implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
```
Append footer at end of file:
```markdown
---
**Next:** [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) — implementation, starting with the platform-agnostic core.
```
- [x] **Step 7: Add scope header + footer to `crates/relicario-core/ARCHITECTURE.md`**
Read `crates/relicario-core/ARCHITECTURE.md`. Insert this header after its H1 (currently `# Architecture: relicario-core`):
```markdown
> **Audience:** contributors editing or extending `relicario-core`. This doc owns the module map for this crate, module-level invariants (e.g., no filesystem, no network), key flows at the module level, and the crate's test architecture. **Does NOT own:** crypto primitives or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)), wire formats (see [../../docs/FORMATS.md](../../docs/FORMATS.md)).
```
Append footer at end of file:
```markdown
---
**Next:** [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.
```
- [x] **Step 8: Add scope header + footer to `crates/relicario-cli/ARCHITECTURE.md`**
Read `crates/relicario-cli/ARCHITECTURE.md`. Insert this header after its H1 (currently `# Architecture: relicario-cli`):
```markdown
> **Audience:** contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. **Does NOT own:** crypto, wire formats, or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/FORMATS.md](../../docs/FORMATS.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)).
```
Append footer at end of file:
```markdown
---
**Next:** [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.
```
- [x] **Step 9: Add scope header + footer to `extension/ARCHITECTURE.md`**
Read `extension/ARCHITECTURE.md`. Insert this header after its H1 (currently `# Architecture: relicario extension`):
```markdown
> **Audience:** contributors editing the browser extension. This doc owns the bundle structure (popup, vault tab, background SW, content scripts), the SW ↔ popup message contract, the component / pane architecture, routing, and the build pipeline. **Does NOT own:** WASM crypto internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)), wire formats (see [../docs/FORMATS.md](../docs/FORMATS.md)), or threat model (see [../docs/SECURITY.md](../docs/SECURITY.md)).
```
Append footer at end of file:
```markdown
---
**End of tour.** For roadmap and in-flight work see [../STATUS.md](../STATUS.md) and [../ROADMAP.md](../ROADMAP.md).
```
- [x] **Step 10: Verify all eight headers are present**
Run:
```bash
grep -l '^> \*\*Audience:\*\*' README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md extension/ARCHITECTURE.md
```
Expected: all eight filenames echoed back. If any file is missing from the output, its header didn't land — go back and add it.
- [x] **Step 11: Verify all "Next:" footers are present**
Run:
```bash
grep -l -E '^\*\*(Next|End of tour)' README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md extension/ARCHITECTURE.md
```
Expected: all eight filenames echoed back.
- [x] **Step 12: Verify README architecture section is trimmed**
Run: `grep -n 'docs/architecture/' README.md`
Expected: zero matches (the broken `docs/architecture/` reference is gone).
Also run: `awk '/^## Architecture/,/^## [^A]/' README.md | wc -l` and inspect — the section between `## Architecture` and the next `##` heading should now be small (under ~15 lines), not the old multi-tree diagram.
- [x] **Step 13: Commit**
Run:
```bash
git add README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md extension/ARCHITECTURE.md
git commit -m "$(cat <<'EOF'
docs: add scope headers + Next: footers to all tour docs
Each of the eight tour docs (README, DESIGN, docs/CRYPTO,
docs/FORMATS, docs/SECURITY, crates/relicario-core/ARCHITECTURE,
crates/relicario-cli/ARCHITECTURE, extension/ARCHITECTURE) now
declares its scope in a blockquote under its H1 and ends with a
single-line "Next:" pointer to the next doc in the canonical
reading order: README → DESIGN → CRYPTO → FORMATS → SECURITY →
core → cli → extension.
Also trimmed README's mid-section "Architecture" stub to a one-
paragraph pointer at DESIGN.md (was duplicating cross-codebase
content and referencing a non-existent docs/architecture/ tree).
Renamed docs/CRYPTO.md's H1 from "Relicario — Architecture" to
"Relicario — Crypto Pipeline" to match the file's renamed scope.
Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
EOF
)"
```
---
## Task 3: Fix incoming links to old paths
**Files (modified as needed):** `CLAUDE.md`, plus whatever other files reference the old paths.
- [x] **Step 1: Find every reference to old paths in markdown files**
Run:
```bash
grep -rn --include='*.md' \
-e '](ARCHITECTURE\.md' \
-e '](\./ARCHITECTURE\.md' \
-e '](docs/ARCHITECTURE\.md' \
-e '](FORMATS\.md' \
-e '](\./FORMATS\.md' \
-e '`ARCHITECTURE\.md`' \
-e '`docs/ARCHITECTURE\.md`' \
-e '`FORMATS\.md`' \
. 2>/dev/null \
| grep -v 'docs/superpowers/test-runs/' \
| grep -v 'docs/superpowers/audits/' \
| grep -v 'docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md' \
| grep -v 'docs/superpowers/plans/2026-05-30-doc-structure-redesign.md'
```
Expected: a list of callsites that need updating. Will definitely include `CLAUDE.md` (the living-docs table and the planning-references list). May include per-crate ARCHITECTURE.md files and some specs in `docs/superpowers/specs/`.
**Important caveat:** the bare token `ARCHITECTURE.md` is also a valid filename suffix for `crates/X/ARCHITECTURE.md` and `extension/ARCHITECTURE.md` (the per-crate docs we are NOT renaming). The grep above uses the `](` (markdown link prefix) and backtick patterns to limit matches to references that look like file paths in prose. If a hit references `crates/<something>/ARCHITECTURE.md` or `extension/ARCHITECTURE.md` — leave that one alone (it's a legitimate per-crate reference).
- [x] **Step 2: For each callsite in the grep output, apply the rewrite rule**
Rewrite rules:
- `ARCHITECTURE.md` (top-level reference) → `DESIGN.md`
- `./ARCHITECTURE.md``./DESIGN.md`
- `docs/ARCHITECTURE.md``docs/CRYPTO.md`
- `FORMATS.md` (top-level reference) → `docs/FORMATS.md`
- `./FORMATS.md``./docs/FORMATS.md`
Inside `CLAUDE.md` specifically, **also** the "Living docs — update discipline" table row labels need updating — that's part of Task 4, not Task 3. Task 3 only fixes link references.
For each file with hits, use `Edit` (or `Edit` with `replace_all`) to apply the rewrites. Show your work in a brief summary at the end of this step: "Updated N references across M files."
- [x] **Step 3: Verify zero old-path references remain**
Re-run the grep from Step 1.
Expected: zero matches (modulo the explicitly-excluded test-runs/, audits/, the spec, and this plan).
If any matches remain, examine and fix (or, if you determine a hit is a legitimate per-crate reference that was caught by the regex, document why it's allowed and move on).
- [x] **Step 4: Verify links resolve (no broken paths)**
For every modified link, confirm the target file exists. Quick spot-check:
```bash
ls -1 DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md extension/ARCHITECTURE.md
```
Expected: all seven files listed (none missing). For relative links inside non-root docs, mentally trace the relative path or `ls` it.
- [x] **Step 5: Commit**
Run:
```bash
git add -u
git commit -m "$(cat <<'EOF'
docs: fix incoming links to renamed/moved doc paths
Rewrites every markdown reference to the old paths:
- ARCHITECTURE.md → DESIGN.md
- docs/ARCHITECTURE.md → docs/CRYPTO.md
- FORMATS.md → docs/FORMATS.md
Touches CLAUDE.md (living-docs table + planning-references list),
per-crate ARCHITECTURE.md cross-refs, and any specs in
docs/superpowers/specs/ that referenced the old paths. Audit
history and test-run logs intentionally left untouched.
Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
EOF
)"
```
---
## Task 4: Update `CLAUDE.md` living-docs table + add three discipline rules
**Files:**
- Modify: `CLAUDE.md`
- [x] **Step 1: Read the current `CLAUDE.md` living-docs section**
Read `CLAUDE.md` and locate two sections:
1. The "Living docs — update discipline" table (the table starting with `| File | What it documents | Update when... |`).
2. The "Planning & design specs" paragraph + "Core references" bullet list (above the table).
- [x] **Step 2: Update the table to use new filenames**
In the table, apply these row-label rewrites:
| Current row label | New row label |
|---|---|
| `` `ARCHITECTURE.md` `` | `` `DESIGN.md` `` |
| `` `docs/ARCHITECTURE.md` `` | `` `docs/CRYPTO.md` `` |
| `` `FORMATS.md` `` | `` `docs/FORMATS.md` `` |
The "What it documents" and "Update when..." cells for `DESIGN.md` and `docs/CRYPTO.md` and `docs/FORMATS.md` should be reviewed and lightly polished if they reference the old filename or scope — but the existing wording is already mostly correct, so only edit if a cell explicitly contradicts the new scope. Don't rewrite for the sake of rewriting.
- [x] **Step 3: Update the "Planning & design specs" core-references list**
If the bullet list above the table references `docs/superpowers/specs/<file>.md` with a specific old path or doc name, leave the bullets alone (those are spec citations, not docs being renamed). If the bullet list references `ARCHITECTURE.md`, `docs/ARCHITECTURE.md`, or `FORMATS.md` in prose, apply the same rewrites as Task 3 Step 2.
- [x] **Step 4: Add three new discipline rules**
Add a new section to `CLAUDE.md` immediately *after* the "Living docs — update discipline" table, titled `### Discipline rules`. Insert this content:
```markdown
### Discipline rules
Three rules to prevent the kind of drift the 2026-05-30 audit found:
1. **Scope-boundary check.** When editing a tour doc, verify the change fits the doc's scope header. If it doesn't, the change belongs in a different doc — move it instead of stretching the scope. Concretely: a sentence about crypto added to `DESIGN.md` belongs in `docs/CRYPTO.md`; a wire-format table added to `docs/CRYPTO.md` belongs in `docs/FORMATS.md`.
2. **Code-constant pinning.** When a tour doc cites a code constant (`VERSION_BYTE = 0x02`, `QUANT_STEP = 50.0`, `MIN_COPIES = 5`, `MANIFEST_SCHEMA_VERSION = 2`, etc.), the doc must cite the source file + line. When the underlying constant changes, grep for the citation pattern and update the docs together with the code change in the same commit. Most drift the audit found was code-constant drift — this rule attacks it at the source.
3. **New-doc rule.** When adding a tour doc, also update (a) `DESIGN.md`'s code-map, (b) the reading-order sequence (the "Next:" footer chain), and (c) the living-docs table above. A new doc that doesn't appear in all three is not done.
```
- [x] **Step 5: Verify `CLAUDE.md` changes**
Run:
```bash
grep -n 'DESIGN.md\|docs/CRYPTO.md\|docs/FORMATS.md' CLAUDE.md
```
Expected: at least three matches (one for each renamed file in the table). Also:
```bash
grep -n 'Discipline rules' CLAUDE.md
```
Expected: one match (the new section heading).
Also verify zero old-path references remain in `CLAUDE.md`:
```bash
grep -nE '`ARCHITECTURE\.md`|`docs/ARCHITECTURE\.md`|`FORMATS\.md`' CLAUDE.md | grep -v 'crates/.*ARCHITECTURE\.md' | grep -v 'extension/ARCHITECTURE\.md'
```
Expected: zero matches.
- [x] **Step 6: Commit**
Run:
```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(CLAUDE.md): update living-docs table + add discipline rules
Table row labels now reference DESIGN.md / docs/CRYPTO.md /
docs/FORMATS.md. Adds three new discipline rules attacking the
structural causes of the 2026-05-30 drift audit findings:
1. Scope-boundary check — content goes in the doc whose scope
header claims it; if it doesn't fit, move it instead of
stretching the header.
2. Code-constant pinning — docs that cite code constants must
cite source file + line; constant changes update doc and
code in the same commit.
3. New-doc rule — adding a tour doc also requires updating
DESIGN's code-map, the Next: footer chain, and this table.
Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
EOF
)"
```
---
## Task 5: Final verification gate
**Files:** none modified in this task — pure verification. If a check fails, fix the relevant earlier commit (don't add a new commit just to patch up missing wording from an earlier task).
- [x] **Step 1: Scope-header presence check**
Run:
```bash
for f in README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md extension/ARCHITECTURE.md; do
if grep -q '^> \*\*Audience:\*\*' "$f"; then
echo "OK $f"
else
echo "FAIL $f (no scope header)"
fi
done
```
Expected: eight `OK` lines, zero `FAIL`. If any FAIL, fix the file's header and amend the Task 2 commit (or add a follow-up commit if amending would be too disruptive).
- [x] **Step 2: "Next:" footer chain check**
Run:
```bash
for f in README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md; do
if grep -q -E '^\*\*Next:\*\*' "$f"; then
echo "OK $f"
else
echo "FAIL $f (no Next: footer)"
fi
done
if grep -q -E '^\*\*End of tour' extension/ARCHITECTURE.md; then
echo "OK extension/ARCHITECTURE.md"
else
echo "FAIL extension/ARCHITECTURE.md (no End of tour footer)"
fi
```
Expected: eight `OK` lines, zero `FAIL`.
- [x] **Step 3: No old paths remain in living docs**
Run the same grep from Task 3 Step 3:
```bash
grep -rn --include='*.md' \
-e '](ARCHITECTURE\.md' \
-e '](\./ARCHITECTURE\.md' \
-e '](docs/ARCHITECTURE\.md' \
-e '](FORMATS\.md' \
-e '](\./FORMATS\.md' \
. 2>/dev/null \
| grep -v 'docs/superpowers/test-runs/' \
| grep -v 'docs/superpowers/audits/' \
| grep -v 'docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md' \
| grep -v 'docs/superpowers/plans/2026-05-30-doc-structure-redesign.md'
```
Expected: zero matches (modulo the excluded paths).
- [x] **Step 4: Renames are git-tracked**
Run:
```bash
git log --follow --oneline DESIGN.md | tail -3
git log --follow --oneline docs/CRYPTO.md | tail -3
git log --follow --oneline docs/FORMATS.md | tail -3
```
Expected: each shows commits *before* the rename (i.e., when the file was `ARCHITECTURE.md` / `docs/ARCHITECTURE.md` / `FORMATS.md`). If any shows only the rename commit and nothing else, `git log --follow` is not picking up the history — likely because of how the rename commit was made. Investigate and fix.
- [x] **Step 5: CLAUDE.md table is current**
Run:
```bash
grep -nE '\| `(DESIGN|docs/CRYPTO|docs/FORMATS)\.md` \|' CLAUDE.md
```
Expected: three matches (one for each renamed file). If fewer, the table row was missed in Task 4 Step 2.
Also run:
```bash
grep -n '### Discipline rules' CLAUDE.md
```
Expected: one match.
- [x] **Step 6: README architecture-section trim verification**
Run:
```bash
awk '/^## Architecture/,/^## [^A]/' README.md | head -20
```
Expected: short paragraph (around 5-8 lines of prose), no codebase tree diagram, and a link to `DESIGN.md`. If the old tree diagram still shows, Task 2 Step 2 didn't land — go back and trim.
- [x] **Step 7: Push**
Once all six checks above pass, push all five commits:
```bash
git push
```
Expected: push succeeds. Working tree is clean (modulo the pre-existing dirt on `.claude/settings.json` etc.).
- [x] **Step 8: Final summary**
Echo a short summary of what landed: 5 commits, file count by category, anything that needed amending. This is for the user's reading pleasure, not a code change.
---
## Done
Verify with the user that all tour docs flow naturally when read in order: `README → DESIGN → docs/CRYPTO → docs/FORMATS → docs/SECURITY → crates/relicario-core/ARCHITECTURE.md → crates/relicario-cli/ARCHITECTURE.md → extension/ARCHITECTURE.md`. If anything reads awkwardly, that's a content polish for a future pass, not a structural problem with this redesign.

View File

@@ -0,0 +1,466 @@
# Vault-tab management surfaces revamp
**Date:** 2026-05-23
**Status:** Spec, awaiting review
**Surface:** Browser extension management panes — `extension/src/popup/components/` (shared between popup and vault tab)
## Problem
Four "management" surfaces in the extension — **Settings**, **Devices**, **Trash**, and **field history** — all shipped in the 1C-β₂ / device-auth waves but in the *pre-fullscreen-redesign* visual language. They read as popup-derived forms stretched across the vault tab, with inconsistent typography, no glyph buttons, no focus rings, and no visual section grouping. Several functional gaps remain alongside the visual debt:
- **Settings**: per-device session-timeout config UI was specced in the vault-tab design (2026-04-27) but never built; the only way to change session behavior today is to edit `chrome.storage.local` directly.
- **Devices**: revocation works via a plain text "revoke" button + browser `confirm()` dialog — functional but inconsistent with the rest of the extension's UX. Device entries don't expose the SHA256 fingerprint (used for verifying against the server-side `devices.json`) or the `added_by` field that's already in `DeviceEntry`.
- **Trash**: per-item purge countdown isn't shown — users see "trashed N days ago" but have to mentally add the retention window to figure out when it'll be gone.
- **History**: the per-item field-history viewer (`field-history.ts`) is only reachable from an item detail page; there's no entry point to discover *which* items have history.
This spec applies the fullscreen visual-language tokens to all four panes and closes the gaps above. It deliberately stays small and ships in the v0.5.x train, in the current `vault.ts` shell — Phase 3 shell rearchitecture is out of scope.
## Goals
- All four management panes adopt the fullscreen visual language: glyph buttons, focus ring, uppercase section headers with 1px bottom rule, accent tokens, required-field pill where applicable.
- Close the four functional gaps above (session timeout UI, revoke button surfacing, fingerprint + added-by display, purge countdown, history index).
- Add **one new pane** — "items with history" index — reachable from a new `◷ history` slot in the sidebar bottom-nav.
- Zero core or wasm changes; zero data-model changes; zero new schema versions.
## Non-goals
- **Phase 3 shell rearchitecture** (three-pane layout, `shell/three-pane.ts`, `keymap.ts`) — separate effort, separate spec.
- **Phase 4 command palette** — deferred to its own brainstorm round.
- **Item-level snapshot history** — option B/C from brainstorm; this spec uses option A (aggregate existing `field_history` per item; no new core storage).
- **Settings as a hub with sub-tabs** — would introduce sub-tab pattern not used elsewhere; defer.
- **Trash polish**: hover-preview, multi-select bulk-restore — defer.
- **Devices polish**: rotate-key flow, "last seen" detail (would need new data) — defer.
- **History polish**: diff view between historical values — defer.
## Scope summary
| Surface | Files touched | New? |
|---|---|---|
| Settings | `popup/components/settings-vault.ts`, `vault.css`/`popup.css` | modify |
| Devices | `popup/components/devices.ts`, new `shared/ssh-fingerprint.ts` | modify + 1 new util |
| Trash | `popup/components/trash.ts` | modify |
| History — index | `popup/components/item-history-index.ts` | **NEW** |
| History — per-item | `popup/components/field-history.ts` | polish only (no rename) |
| Glyph constants | `shared/glyphs.ts` | depends-on-or-creates |
| Time helper | `shared/relative-time.ts` | **NEW** (extracted from 3 call sites) |
| Routing | `vault/vault.ts` | add `#history` + `#history/<itemId>` routes |
| Nav | sidebar bottom-nav | grows 3 → 4 (`▦ trash · ⌬ devices · ⚙ settings · ◷ history`) |
---
## Architecture
### Component map
```
extension/src/
├── shared/
│ ├── glyphs.ts ← depends on (or creates if absent):
│ │ GLYPH_TRASH ▦, GLYPH_DEVICES ⌬,
│ │ GLYPH_SETTINGS ⚙, GLYPH_LOCK ⏻,
│ │ GLYPH_HISTORY ◷, GLYPH_REVOKE ⊘,
│ │ GLYPH_RESTORE ⤺, GLYPH_REVEAL ⊙,
│ │ GLYPH_COPY ⧉
│ └── relative-time.ts ← NEW (small util — inlined in 3 places today)
├── popup/components/
│ ├── settings-vault.ts ← rewrite layout; add session-timeout row
│ ├── devices.ts ← add fingerprint, "added by"; surface revoke button
│ ├── trash.ts ← add per-item purge countdown
│ ├── field-history.ts ← visual polish only (filename kept)
│ └── item-history-index.ts ← NEW: "items with history" index
└── vault/
├── vault.ts ← add #history routes + bottom-nav slot
├── vault.css ← four shared utility classes (below)
└── popup.css ← same classes (shared components render in both)
```
### SW message protocol — 99% reuse
| Capability | Message | Status |
|---|---|---|
| Read/write vault settings | `get_vault_settings` / `update_vault_settings` | exists |
| Read/write session timeout (per-device) | `get_session_config` / `update_session_config` | exists |
| List active + revoked devices | `list_devices` / `list_revoked` | exists |
| Register / revoke device | `register_this_device` / `revoke_device` | exists |
| List trashed, restore, purge | `list_trashed` / `restore_item` / `purge_item` / `purge_all_trash` | exists |
| Per-item field history | `get_field_history` | exists (reused for index + per-item) |
**No SW shape changes.** Fingerprint is computed client-side in `devices.ts` via `crypto.subtle.digest('SHA-256', …)` against the base64-decoded ed25519 key blob from `DeviceEntry.public_key`. Result is formatted as `SHA256:<base64-no-pad>` to match the SSH convention (and what `relicario device list` prints from `core::device::fingerprint()`). Pure extension change — no message round-trip, no WASM export, no Rust change.
### Shared CSS utility classes
Defined in `vault.css` and `popup.css` (shared because components render in both contexts):
```css
.section-header {
text-transform: uppercase;
font-weight: 500;
letter-spacing: 1px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 4px;
margin-bottom: 10px;
}
.glyph-btn {
min-width: 28px;
font-family: ui-monospace, monospace;
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border-subtle);
border-radius: 3px;
padding: 2px 6px;
cursor: pointer;
}
.glyph-btn:hover { color: var(--text); background: var(--bg-input); }
.glyph-btn:focus-visible { box-shadow: var(--focus-ring); outline: none; }
.glyph-btn[data-danger]:hover { color: var(--danger); border-color: var(--danger); }
.kv-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 4px 0;
}
.kv-row > .k { color: var(--text-muted); }
.kv-row > .v { color: var(--text); font-variant-numeric: tabular-nums; }
.fingerprint {
font-family: ui-monospace, monospace;
color: var(--text-muted);
font-size: 11px;
word-break: break-all; /* wraps to two lines in popup (~360px) */
}
```
### Visual language reference
All tokens come from the existing fullscreen UX redesign spec (`2026-04-30-relicario-fullscreen-ux-redesign-design.md`, "Visual language" section). No new tokens introduced. New glyph constants added to `shared/glyphs.ts` if not already present: `GLYPH_HISTORY ◷`, `GLYPH_REVOKE ⊘`, `GLYPH_RESTORE ⤺`, `GLYPH_REVEAL ⊙`, `GLYPH_COPY ⧉`.
---
## A. Settings pane
Two-tier section grouping makes the storage distinction explicit: **VAULT SETTINGS · synced** lives in the encrypted vault (replicated via git), **THIS DEVICE · local** lives in `chrome.storage.local` (per-device, not synced). **ACTIONS** is destructive/expensive operations.
```
◀ settings
unsaved · ⌘+S to save no changes
VAULT SETTINGS · synced
─────────────────────────────────────────────────────────────
┌──────────────────────────┐ ┌──────────────────────────┐
│ RETENTION │ │ GENERATOR │
│ trash [30 days ▾] │ │ length 24 │
│ history [last 5 ▾] │ │ words 4 │
│ │ │ [ configure defaults ↻ ] │
└──────────────────────────┘ └──────────────────────────┘
┌──────────────────────────┐
│ ATTACHMENTS │
│ max size [25 MB ▾] │
└──────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ AUTOFILL ORIGINS │
│ github.com acknowledged 2d ago ⊘ │
│ gitlab.adlee.work acknowledged 5d ago ⊘ │
└─────────────────────────────────────────────────────────┘
THIS DEVICE · local
─────────────────────────────────────────────────────────────
┌──────────────────────────┐
│ SESSION │
│ ○ lock every time │
│ ● after inactivity │
│ [15 min ▾] │
└──────────────────────────┘
ACTIONS
─────────────────────────────────────────────────────────────
[ backup & restore ] [ import from… ]
```
### Decisions
- **Two-tier grouping** with `· synced` / `· local` muted suffixes makes storage scope unambiguous without being preachy.
- **Two-column where fields are small** (Retention ↔ Generator; Attachments standalone in row 2). Collapses to single-column under 720px viewport — same rule the login form uses.
- **Session timeout** wires the existing `get_session_config` / `update_session_config` SW messages to a radio (`every_time` / `inactivity`) + minutes dropdown (5/15/30/60). Already-validated config shape from the vault-tab spec.
- **Form header** reuses the "unsaved · ⌘+S to save" / "no changes" subtitle from the form-layout spec — gives Settings the same dirty-state feedback as item edits.
- **Generator section** keeps the current "configure defaults" button opening the popover from Phase 2A — no inline expansion.
- **Actions** uses text-labelled buttons (not glyph buttons) since they open dedicated panes; text is clearer than icons for navigation-style actions.
---
## B. Devices pane
Single column (this is a list, not a form). Three-line per-entry rhythm: name (+ `← you` marker or `⊘` revoke glyph), full SHA256 fingerprint, then `added X ago · by Y`.
```
◀ devices
ACTIVE · 3
─────────────────────────────────────────────────────────────
⌬ Aaron's laptop ← you
SHA256:8f3a:c7d2:1e44:9b08:6f55:a201:de9c:4477
added 2 months ago · by Aaron
⌬ Aaron's phone ⊘
SHA256:9c11:e4f8:2a91:db32:7c0e:51bb:e8a4:1f6d
added 3 weeks ago · by Aaron's laptop
⌬ work-laptop ⊘
SHA256:b277:35aa:c1e0:8f44:62b9:0d3e:7c1f:5d92
added 8 days ago · by Aaron's laptop
REVOKED · 1
─────────────────────────────────────────────────────────────
▸ show 1 revoked device
```
### Revoke confirmation — inline two-step
Clicking `⊘` expands a confirmation panel in place (no modal):
```
⌬ Aaron's phone
SHA256:9c11:e4f8:2a91:db32:…
added 3 weeks ago · by Aaron's laptop
┌─────────────────────────────────────────────────────┐
│ Revoke this device? It won't be able to sign │
│ commits or push changes after revocation. │
│ │
│ [ cancel ] [ revoke ] │
└─────────────────────────────────────────────────────┘
```
### Unregistered state — top banner
```
◀ devices
┌─────────────────────────────────────────────────────────┐
│ This device isn't registered. │
│ Registering generates an ed25519 keypair and adds the │
│ public key to .relicario/devices.json on the remote. │
│ │
│ [ register this device ] │
└─────────────────────────────────────────────────────────┘
ACTIVE · 2
─────────────────────────────────────────────────────────────
```
### Decisions
- **Full fingerprint shown** (no truncation): verifiability against `.relicario/devices.json` is the whole point of displaying it. Popup-width wrapping handled by `.fingerprint { word-break: break-all }` — wraps to two lines under ~360px.
- **`by Y` semantics**: `DeviceEntry.added_by` — the name of the device that signed the registration commit. Already in the data model, just unsurfaced today.
- **Inline two-step revoke** keeps the lightness of the rest of the extension's UX; modal would feel disproportionate. The current device gets no revoke button (the CLI keeps the "revoke self" escape hatch since that needs different post-revoke handling).
- **Revoked section** collapsed by default with count; expanded entries get the same three-line rhythm minus the revoke button, plus `revoked X ago · by Y`.
- **Unregistered banner**: fleshed-out copy explaining what registration *does* (current one-liner feels mysterious per memory of past confusion). Same flow underneath — click → modal with device-name input → `register_this_device` SW message.
---
## C. Trash pane
```
◀ trash
3 items · oldest purges in 22 days
─────────────────────────────────────────────────────────────
🔑 GitHub login ⤺
trashed 8 days ago · purges in 22 days
📝 Recovery note ⤺
trashed 12 days ago · purges in 18 days
🔑 old-aws-root ⤺
trashed 18 days ago · purges in 12 days
─────────────────────────────────────────────────────────────
[ empty trash ]
```
### Decisions
- **Two-line per-entry**: type icon + name + `⤺` restore glyph; muted second line `trashed X ago · purges in Y days`.
- **Per-item purge countdown** computed client-side from `trashed_at + retention_seconds - now` and formatted via `shared/relative-time.ts`. Updates on pane render (no live timer — sub-day precision unnecessary).
- **Header summary stays** ("3 items · oldest purges in 22 days") — useful at-a-glance.
- **Destructive empty button anchored bottom-right** — separated from per-row restore buttons to reduce mis-clicks. Confirmation flow unchanged from today.
- **Sort**: trashed-date descending (newest first). Defer "sort by purge urgency" toggle — not strongly requested and adds toolbar real estate.
- **Type icons** stay as today (emoji per item type) — the global glyph treatment is for *action* buttons; type icons are content-classification and read better as the existing emoji set.
---
## D. History — index pane (NEW)
Reachable from a new `◷ history` bottom-nav slot. Sorted by most-recent-change descending.
```
◀ history
5 items have field history
─────────────────────────────────────────────────────────────
🔑 GitHub login
3 changes · last 2 days ago
🔑 AWS prod
1 change · last 2 weeks ago
📝 Recovery note
2 changes · last 3 weeks ago
🔑 Cloudflare
1 change · last 1 month ago
🌐 personal-email
4 changes · last 2 months ago
```
### Decisions
- **Implementation**: iterate manifest entries, fetch + decrypt each item (already cached in session state where decrypted), inspect `field_history` map; emit entries that have ≥1 history-tracked field with non-empty history. Count = sum of `field_history[*].length`. Last-changed = max `replaced_at` across all history entries.
- **Click row** → routes to `#history/<itemId>` (per-item view below).
- **Empty state** when no items have history: *"No field history yet. Edits to passwords, TOTP secrets, and concealed fields will appear here."*
- **No excerpts** in the index — keeping it lean; the per-item view is one click away.
---
## E. History — per-item view (existing, polished)
Existing component (`field-history.ts`, filename kept). Visual polish only — apply the section-header rule, glyph buttons, focus ring, accent tokens. **No structural changes to content layout.**
```
◀ history · GitHub login
PASSWORD · 3 entries
─────────────────────────────────────────────────────────────
current ●●●●●●●●●●●●●●●●●●●●●●●● ⊙ ⧉
set 2 days ago
───
previous ●●●●●●●●●●●●●●●●●●● ⊙ ⧉
3 weeks ago
───
previous ●●●●●●●●●●●●●●●● ⊙ ⧉
2 months ago
TOTP_SECRET · 1 entry
─────────────────────────────────────────────────────────────
current ●●●●●●●●●●●●●●●●●●●●●●●● ⊙ ⧉
set 1 month ago
───
previous ●●●●●●●●●●●●●●●●●●● ⊙ ⧉
(created · 3 months ago)
```
### Decisions
- **Filename kept** as `field-history.ts` per user feedback during brainstorm.
- **Routing**: continues to be reachable from item-detail "view history" button (`#history/<itemId>`). Now also reachable by drilling into the history index pane.
- **Reveal/copy glyphs** updated to `⊙ ⧉` constants from `shared/glyphs.ts`.
- **Per-field uppercase header** + 1px rule applied — matches the new visual rhythm.
- **Values stay concealed by default** (existing behavior). Reveal toggle and copy button per entry. Values held in component-local `valueStore` map (not DOM attributes) — existing security pattern preserved.
---
## Routing & sidebar nav changes
### `vault.ts` `VaultView` union changes
```ts
type VaultView =
| 'list' | 'detail' | 'add' | 'edit'
| 'trash' | 'devices' | 'settings' | 'settings-vault'
| 'field-history' // existing — per-item view (internal dispatch key kept)
| 'history' // NEW — index pane only
| 'backup' | 'import'
```
The user-facing hash changes (`#history` is the new entry point, `#history/<id>` is the per-item view), but the internal dispatch keeps `'field-history'` for the per-item view to minimize the diff to working code. Normalization happens in `parseHash`:
- `#history``{ view: 'history' }` → index pane (`item-history-index.ts`)
- `#history/<itemId>``{ view: 'field-history', id: <itemId> }` → per-item view (`field-history.ts`)
- `#field-history/<itemId>` → rewritten to `#history/<itemId>` in the address bar, then resolved as above (one release of backward compat for any bookmarked URLs)
### Sidebar bottom-nav
```
▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock
```
Five glyph buttons in a row at ~240px sidebar width: comfortable at 1ch each + padding. Verified.
---
## Testing
UI work — verification is mostly manual smoke:
- Each pane loads, renders, round-trips edits
- Settings round-trip: change retention/session/attachments → reload → values persist; session-timeout actually fires lock after configured minutes
- Devices: fingerprint string matches `relicario device list` CLI output; revoke happy path + cancel; unregistered banner → register flow → confirm
- Trash: per-item purge countdown updates correctly; empty trash → confirms then purges
- History: index sorted by recency; click drills in; empty state when no history; both `#history/<id>` and the item-detail entry point reach the same view
- Cross-context: each shared component renders correctly in both popup (~360px) and vault tab (full)
**Unit tests** — only where logic warrants:
- `shared/relative-time.ts` — table-driven test of fixture timestamps → strings
- Purge-countdown formatting
No new test infrastructure. Extension currently has no snapshot tests per inventory; not adding any here.
---
## Rollout
- Single PR, v0.5.x train.
- No data-model migration, no schema change, no core or wasm changes.
- Purely `extension/src/`: one new shared util, one new pane file, four modified components, CSS additions, two routing additions.
- Doc updates: `STATUS.md` move-to-recent on land; `extension/ARCHITECTURE.md` note the new `◷ history` route + 4th sidebar slot.
---
## Risks
| Risk | Mitigation |
|---|---|
| Bottom-nav crowding (4 + lock = 5 items at ~240px sidebar) | Glyphs are 1ch each; ample room verified, but confirm at narrowest viewport during smoke |
| Fingerprint length in popup context (~330px monospace) | CSS `word-break: break-all` on `.fingerprint`; no truncation |
| `shared/glyphs.ts` may not exist yet | Spec creates it if absent (called out in §1) — depends-on-or-creates |
| Decrypting all items for the history index pane | Most items are already cached after a session warm-up; cost is per-pane-load not per-event; acceptable for family-vault item counts |
| Inline revoke confirmation could be missed (no modal blocker) | Two-step pattern matches other extension confirmations (delete item, empty trash); copy is explicit about consequence |
---
## Out of scope (deferred to future rounds)
- Phase 3 shell rearchitecture (three-pane layout, command palette, drag-resize panes)
- Phase 4 command palette
- Item-level snapshot history (option B/C)
- Settings-as-hub with sub-tabs
- Trash multi-select / bulk-restore / hover preview
- Devices rotate-key flow / "last seen" detail
- History diff view between adjacent values
- Whole-revamp animations or transitions

View File

@@ -0,0 +1,245 @@
# Doc Structure Redesign — Design
**Date:** 2026-05-30
**Status:** Proposed
**Source:** Drift audit run on this same date (three parallel agents over the living docs) + follow-up brainstorm with the user.
**Effort estimate:** S (one focused afternoon — renames + headers + link-fixes, no content rewrites).
## Summary
The living docs split into roughly thirteen files spread across the repo root, `docs/`, `crates/*`, and `extension/`. Three of them are called `ARCHITECTURE.md` and overlap in scope, which is exactly where the drift audit clustered (top-level called `0x01` while code shipped `0x02`, `FORMATS.md` listed 16-hex AttachmentIds while code used 32, per-crate maps missed five public modules and several CLI commands). This design keeps the file count roughly the same but **renames docs by topic, pins each doc to an explicit scope, and chains them into a single reading order**. A contributor (or future-you after a long break) lands on `README`, gets walked through `DESIGN → docs/CRYPTO → docs/FORMATS → docs/SECURITY → crates/*/ARCHITECTURE.md → extension/ARCHITECTURE.md`, never sees two files with the same name, and never has to guess which doc owns a given fact.
The drift fixes themselves landed in three commits earlier today (`210232d`, `cf7478d`, `fa659eb`). This redesign attacks the *structural* causes of the drift so the next audit has less to find.
## Findings addressed
The drift audit produced a punch list across three themes. The structural causes this redesign attacks are:
- **Three files named `ARCHITECTURE.md` at three levels with overlapping scope.** Top-level vs `docs/ARCHITECTURE.md` overlap drove the `0x01`-vs-`0x02` divergence and the `MIN_COPIES` confusion. Renaming the top-level to `DESIGN.md` and the `docs/` one to `docs/CRYPTO.md` eliminates the name collision and makes each doc's topic obvious from its filename.
- **No scope boundaries between docs.** When the wire-format byte changed in code, there was no rule saying "the version-byte diagram lives in `docs/CRYPTO.md`, not in `FORMATS.md` or `DESIGN.md`," so the diagrams in two docs drifted apart. Adding explicit scope headers to each tour doc makes "where does this fact go?" unambiguous.
- **No reading order signposts.** A cold reader couldn't tell whether to start at README, top-level ARCHITECTURE, or `docs/ARCHITECTURE.md`. "Next:" footers on every tour doc make a single canonical path.
- **`FORMATS.md` sitting at the repo root while every other reference doc sits in `docs/`.** Asymmetry adds cognitive load. Moving it to `docs/FORMATS.md` aligns with the rest.
- **`CLAUDE.md`'s "Living docs — update discipline" table is the only place the scope-rules are written.** It lists *when* to update each doc but not *what each doc owns vs does not own*. The new scope headers act as on-doc enforcement; the CLAUDE.md table is updated to point at the new filenames and adds three new discipline rules.
Out of scope (intentionally): STATUS.md drift habit (a behavioural problem, not structural); per-crate `main.rs:NNNN` line-number citations going stale when handlers move (a habit nudge — cite by function name, not line — but cross-cutting and worth its own pass).
## Goals
- A cold reader can flow from `README → DESIGN → docs/CRYPTO → docs/FORMATS → docs/SECURITY → crates/*/ARCHITECTURE.md → extension/ARCHITECTURE.md` without ping-ponging or guessing which doc owns what.
- Every tour doc declares its scope in a 1-2 sentence header at the top and points at the next doc in a single-line footer at the bottom.
- The drift surface shrinks: no two docs claim to own the same fact.
- Migration is mechanical (renames + header additions + link fixes); no content is rewritten. The drift audit already cleaned the content.
## Non-goals
- Renaming per-crate or extension `ARCHITECTURE.md` files. Their nesting (`crates/X/`, `extension/`) already disambiguates them.
- Adding new ARCHITECTURE.md files for `relicario-server` or `relicario-wasm`. Both crates are small enough that a per-crate doc would be more maintenance than help. Add later if either grows.
- Touching `STATUS.md`, `ROADMAP.md`, `CHANGELOG.md`. Their roles are well-defined and the audit found no structural issue there.
- Touching `docs/superpowers/specs/` or `docs/superpowers/plans/`. Intentionally accumulating.
- Adding `CONTRIBUTING.md`. The "me + Claude, contributor-ready" audience choice means we keep the docs welcoming but don't bolt on contributor-onboarding pages we don't need yet.
## Target structure
```
README.md Front door (already great). Trim its mid-section
"Architecture" stub to a one-paragraph pointer at
DESIGN.md. Pitch + security model + quick-start +
reference image + recovery + roadmap teaser stay.
DESIGN.md NEW NAME (replaces top-level ARCHITECTURE.md).
The system tour: four codebases, contracts between
them, secrets map, build matrix, global code-map index.
docs/
├── CRYPTO.md NEW NAME (renamed from docs/ARCHITECTURE.md).
│ Crypto pipeline + vault flows + DCT embedding +
│ high-level encrypted-file-format diagram.
├── FORMATS.md MOVED from repo root.
│ Wire formats: .enc blob layout, params.json,
│ devices.json, manifest schema, .relbak envelope,
│ ID formats, settings JSON schema.
└── SECURITY.md UNCHANGED LOCATION.
Threat model, attacker scenarios, device auth,
env-var trust surface.
crates/
├── relicario-core/ARCHITECTURE.md UNCHANGED (nesting disambiguates).
├── relicario-cli/ARCHITECTURE.md UNCHANGED.
├── relicario-server/ No doc — too small.
└── relicario-wasm/ No doc — too small.
extension/ARCHITECTURE.md UNCHANGED.
(unchanged: STATUS / ROADMAP / CHANGELOG / CLAUDE / LICENSE / docs/superpowers/)
```
**Reading order:**
```
README → DESIGN → docs/CRYPTO → docs/FORMATS → docs/SECURITY
→ crates/relicario-core/ARCHITECTURE.md
→ crates/relicario-cli/ARCHITECTURE.md
→ extension/ARCHITECTURE.md
```
Realized by two conventions on every tour doc:
1. **Scope header (top, 1-2 sentences):** *"This doc owns X. See Y for Z."*
2. **"Next:" footer:** a one-line pointer to the next doc in the tour.
## Per-file scope definitions
The exact scope headers + "Next:" footers to be pasted at the top and bottom of each tour doc.
### `README.md`
> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md).
Existing-content delta: the current "Architecture" section gets trimmed to one paragraph pointing at `DESIGN.md`. Quick start / Reference image / Recovery / Roadmap sections stay.
Footer: `Next: [DESIGN.md](DESIGN.md) — the system tour.`
### `DESIGN.md` *(new name; replaces top-level `ARCHITECTURE.md`)*
> **Audience:** anyone wanting to understand the system at the cross-codebase level. This doc owns the four-codebase map, inter-codebase contracts, the secrets map (what secret lives where), the build matrix, and the global code-map index. **Does NOT own:** crypto pipeline details (see `docs/CRYPTO.md`), wire formats (see `docs/FORMATS.md`), threat model (see `docs/SECURITY.md`), per-crate module maps (see `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md`).
Footer: `Next: [docs/CRYPTO.md](docs/CRYPTO.md) — the crypto pipeline that backs this design.`
### `docs/CRYPTO.md` *(new name; renamed from `docs/ARCHITECTURE.md`)*
> **Audience:** anyone evaluating or auditing the crypto. This doc owns Argon2id parameters and rationale, XChaCha20-Poly1305 rationale, vault creation/unlock flow diagrams, DCT-steganography embed and extract flows, and the high-level encrypted-file-format diagram. **Does NOT own:** byte-level schemas or JSON shapes (see `docs/FORMATS.md`), attacker scenarios (see `docs/SECURITY.md`), or per-module crypto implementation (see `crates/relicario-core/ARCHITECTURE.md`).
Footer: `Next: [FORMATS.md](FORMATS.md) — the byte-level wire formats.`
### `docs/FORMATS.md` *(moved from repo root)*
> **Audience:** anyone implementing a compatible client or reading raw vault bytes. This doc owns the `.enc` blob layout, `params.json` / `salt` / `devices.json` / `revoked.json` shapes, the manifest JSON schema, the `.relbak` envelope, item-ID formats, and the settings JSON schema. **Does NOT own:** why these formats look this way (see `docs/CRYPTO.md`), threat model around them (see `docs/SECURITY.md`), or Rust struct internals (see `crates/relicario-core/ARCHITECTURE.md`).
Footer: `Next: [SECURITY.md](SECURITY.md) — the threat model.`
### `docs/SECURITY.md` *(unchanged location)*
> **Audience:** auditors and curious users. This doc owns the threat model, attacker-scenarios table, device-authentication model, env-var trust surface, and known limitations. **Does NOT own:** crypto primitive details (see `docs/CRYPTO.md`), wire formats (see `docs/FORMATS.md`), or implementation (see `crates/*/ARCHITECTURE.md`).
Footer: `Next: [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) — implementation, starting with the platform-agnostic core.`
### `crates/relicario-core/ARCHITECTURE.md`
> **Audience:** contributors editing or extending `relicario-core`. This doc owns the module map for this crate, module-level invariants (e.g., no filesystem, no network), key flows at the module level, and the crate's test architecture. **Does NOT own:** crypto primitives or threat model (see `docs/CRYPTO.md`, `docs/SECURITY.md`), wire formats (see `docs/FORMATS.md`).
Footer: `Next: [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.`
### `crates/relicario-cli/ARCHITECTURE.md`
> **Audience:** contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. **Does NOT own:** crypto, wire formats, or threat model (see `docs/`).
Footer: `Next: [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.`
### `extension/ARCHITECTURE.md`
> **Audience:** contributors editing the browser extension. This doc owns the bundle structure (popup, vault tab, background SW, content scripts), the SW ↔ popup message contract, the component / pane architecture, routing, and the build pipeline. **Does NOT own:** WASM crypto internals (see `crates/relicario-core/ARCHITECTURE.md`), wire formats (see `docs/FORMATS.md`), or threat model (see `docs/SECURITY.md`).
Footer: `End of tour. For roadmap and in-flight work see [../STATUS.md](../STATUS.md) and [../ROADMAP.md](../ROADMAP.md).`
## Migration
Five sequential commits. Each is mechanical; no content is rewritten.
### Commit 1 — Renames
```bash
git mv ARCHITECTURE.md DESIGN.md
git mv docs/ARCHITECTURE.md docs/CRYPTO.md
git mv FORMATS.md docs/FORMATS.md
```
No content changes. Git tracks the renames so `git blame` / `git log --follow` survive.
### Commit 2 — Scope headers + "Next:" footers on the eight tour docs
Add the headers and footers verbatim as listed in the **Per-file scope definitions** section above. Also trim `README.md`'s current "Architecture" section to a one-paragraph pointer at `DESIGN.md` in the same commit.
### Commit 3 — Fix incoming links to old paths
Greppable list of paths to update:
| Old path | New path |
|---|---|
| `ARCHITECTURE.md` (top-level reference) | `DESIGN.md` |
| `docs/ARCHITECTURE.md` | `docs/CRYPTO.md` |
| `FORMATS.md` (top-level reference) | `docs/FORMATS.md` |
Known callsites to update:
- `CLAUDE.md` — the "Living docs — update discipline" table + the "Planning & design specs" core-references list.
- `README.md` — the architecture tree on line ~160 shows `docs/architecture/` which doesn't exist; fix in this pass.
- `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md` — cross-references to top-level ARCHITECTURE.md or FORMATS.md.
- `docs/superpowers/specs/*.md` — some specs reference the old paths; update those whose specs are still load-bearing.
Verification command (run before this commit):
```bash
grep -rn --include='*.md' -E '(\bARCHITECTURE\.md\b|\bdocs/ARCHITECTURE\.md\b|\b/FORMATS\.md\b|^FORMATS\.md\b)' . \
| grep -v docs/superpowers/test-runs/ \
| grep -v docs/superpowers/audits/
```
The grep should return zero matches after this commit (modulo intentional references in audit / test-run history, which we leave alone).
### Commit 4 — Update `CLAUDE.md`
Two changes to `CLAUDE.md`:
1. Update the "Living docs — update discipline" table with the new filenames (`DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`).
2. Add three new rules (see **Maintenance discipline** below).
3. Update the "Planning & design specs" core-references list to point at `docs/CRYPTO.md` / `docs/FORMATS.md` if it currently points elsewhere.
### Commit 5 — Verification
A read-through commit (no content changes; this is just where we confirm the work). The verification checklist in the **Verification** section below runs cleanly. If it doesn't, fix and amend the relevant commit.
## Maintenance discipline
Three rules added to `CLAUDE.md` to prevent the kind of drift the audit found:
1. **Scope-boundary check.** When editing a tour doc, verify the change fits the doc's scope header. If it doesn't, the change belongs in a different doc — move it instead of stretching the scope. (Concretely: a sentence about crypto added to `DESIGN.md` belongs in `docs/CRYPTO.md`; a wire-format table added to `docs/CRYPTO.md` belongs in `docs/FORMATS.md`.)
2. **Code-constant pinning.** When a tour doc cites a code constant (`VERSION_BYTE = 0x02`, `QUANT_STEP = 50.0`, `MIN_COPIES = 5`, `MANIFEST_SCHEMA_VERSION = 2`, etc.), the doc must cite the source file + line. When the underlying constant changes, grep for the citation pattern and update the docs together with the code change in the same commit. Most of the drift the audit found was code-constant drift; this rule attacks it at the source.
3. **New-doc rule.** When adding a tour doc, also update (a) `DESIGN.md`'s code-map, (b) the reading-order sequence (the "Next:" footer chain), and (c) the `CLAUDE.md` living-docs table. A new doc that doesn't appear in all three is not done.
## Verification
Run as part of Commit 5. All checks must pass.
1. **Scope-header presence.** Every tour doc (`README`, `DESIGN`, `docs/CRYPTO`, `docs/FORMATS`, `docs/SECURITY`, `crates/relicario-core/ARCHITECTURE`, `crates/relicario-cli/ARCHITECTURE`, `extension/ARCHITECTURE`) has its scope header at the top, matching the wording in this spec.
2. **"Next:" footer chain.** Each tour doc except `extension/ARCHITECTURE.md` ends with a `Next:` footer pointing at the next doc in the canonical order. `extension/ARCHITECTURE.md` ends with the "End of tour" pointer at STATUS/ROADMAP.
3. **No broken links.** Every link in every tour doc resolves to an existing file. Verified with a markdown link checker or by hand-grepping for `](./` / `](../` references and confirming the target exists.
4. **No old paths remain.** The grep in Commit 3 returns zero matches outside `docs/superpowers/test-runs/` and `docs/superpowers/audits/`.
5. **`CLAUDE.md` table is current.** The "Living docs — update discipline" table lists the new filenames; the three new discipline rules are present.
6. **Renames are git-tracked.** `git log --follow DESIGN.md` shows history continuous from the old `ARCHITECTURE.md`. Same for `docs/CRYPTO.md` and `docs/FORMATS.md`.
7. **README architecture section trimmed.** `README.md`'s mid-section "Architecture" is at most one paragraph and points at `DESIGN.md`.
## Out-of-scope safeties
Things this design intentionally does *not* address; flagging for honesty:
- **STATUS.md drift habit** (shipped work lingering as "in progress"): a behavioural issue, not structural. The audit caught it; the fix was manual. A future pass might add a release-checklist hook or a pre-tag CI gate.
- **Per-crate `ARCHITECTURE.md` line-citation drift** (e.g., `main.rs:NNNN` references stale after handlers moved into `commands/`): partially addressed by rule 2 (code-constant pinning), but not fully. A future habit nudge — cite by function name, not by line number — is worth landing later but is cross-cutting and out of scope here.
- **`docs/superpowers/specs/` and `plans/` accumulation**: intentional. Not touched.
## Footnote — alternative approaches considered
Three approaches were brainstormed before settling on this design (full details in the conversation that produced this spec, archived nowhere because that's how brainstorms work):
- **Approach B — README expands; supporting docs collapse.** Fold the top-level `ARCHITECTURE.md` and `docs/ARCHITECTURE.md` into one big doc (or into README). Rejected: the combined doc gets long, and "organic flow" suffers when one doc covers from quick-start to crypto pipeline to module boundaries. README starts to do too much.
- **Approach C — Keep current files, add reading paths.** Add a top-level `READING-ORDER.md` and grow scope headers on each existing doc. Rejected: doesn't fix the three-files-named-`ARCHITECTURE.md` cognitive cost. The drift surface stays the same; we just navigate it better.
- **Approach A — Tour-shaped + topic-named** *(chosen).* Filenames carry meaning, linear flow is unambiguous, drift surface shrinks by killing the 3× `ARCHITECTURE.md` overload.

View File

@@ -1,10 +1,6 @@
# Architecture: relicario extension
> Strategic-depth doc for the `extension/` codebase. Pairs with `/CLAUDE.md`
> at the repo root (project-level summary) and the typed-items design spec
> under `docs/superpowers/specs/`. Things that are easy to recover from
> reading code are deliberately omitted; things that are not — invariants,
> multi-file control flow, design rationale — go here.
> **Audience:** contributors editing the browser extension. This doc owns the bundle structure (popup, vault tab, background SW, content scripts), the SW ↔ popup message contract, the component / pane architecture, routing, and the build pipeline. **Does NOT own:** WASM crypto internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)), wire formats (see [../docs/FORMATS.md](../docs/FORMATS.md)), or threat model (see [../docs/SECURITY.md](../docs/SECURITY.md)).
## What this codebase is for
@@ -138,6 +134,10 @@ before any new render.
`renderConcealedRow`, `renderSignatureBlock`, `renderSections*`)
consumed by every type. Mounting is the caller's job; after mount,
`wireFieldHandlers(scope)` binds the reveal/copy click handlers once.
- `form-header.ts` — extracted `renderFormHeader({ title, subtitle, ...})`
helper used by every type's `renderForm` (shared `.form-header` CSS,
static "esc to cancel" subtitle in fullscreen mode). Takes an options
object so callers don't need to remember positional argument order.
- `generator-panel.ts` — inline password / passphrase generator. Mounts
inside any host element; round-trips knob changes through the SW's
`generate_password` / `generate_passphrase` (debounced 150ms). Has two
@@ -150,28 +150,65 @@ before any new render.
bytes via WASM (defense in depth — see
`router/popup-only.ts:223-228`).
- `settings.ts` — device-local UX settings (capture toggle, prompt
style), trash/devices/sync-now buttons, blacklist editor.
style), trash/devices/sync-now buttons, blacklist editor. Revamped in
commit `299e7db` to split synced (vault) vs local (device) sections
and surface the per-device session-timeout UI (radio + minutes input).
- `settings-vault.ts` — vault-wide settings (retention, generator
defaults, autofill origin acks). Reads/writes via the SW's
`get_vault_settings` / `update_vault_settings`.
- `trash.ts` — soft-delete listing with restore + purge buttons.
- `devices.ts` — device list with revoke. Inline "register this device"
flow lives here (banner shown when current device is not in the list);
see commit `a7dbf35`.
- `settings-security.ts` — security sub-pane of the vault-tab settings
shell: three-state recovery QR display (hidden → revealed → printed)
and an inline devices summary. Mounted from the settings left-nav.
Restored from main in commit `8baef5b` after the Stream C real
implementation landed.
- `trash.ts` — soft-delete listing with per-item purge countdown
(via `shared/relative-time.ts::daysUntilPurge`), glyph restore (`⤺`),
and a bottom-right destructive "empty trash" button.
- `devices.ts` — device list. Three-line rhythm per row: name + revoke
glyph (`⊘` with inline two-step confirm — no browser modal), full
SHA256 fingerprint (computed in-popup via `shared/ssh-fingerprint.ts`
— no SW round-trip), `added X ago · by Y` meta. Inline "register this
device" banner shown when current device is not in the list.
- `field-history.ts` — audit-log of value changes on a single item;
driven by the SW's `get_field_history` which calls into WASM
`get_field_history(item_json)`.
`get_field_history(item_json)`. Section header per field
(`PASSWORD · N entries`); reveal/copy via explicit glyph buttons
(decoupled from row click); revealed values colorized via
`shared/password-coloring.ts`.
- `item-history-index.ts` — top-level history pane: iterates the
manifest and fans out one `get_field_history` per item, lists those
with ≥1 entry sorted by recency. Click drills into `field-history.ts`
for the per-item view. Reachable via `#history` (the sidebar slot)
and from the URL.
### `src/vault/`
- `vault.ts` — fullscreen tab entry. Hash-based router (`#detail/<id>`,
`#add/<type>`, `#trash`, `#devices`, `#settings`, `#settings-vault`,
`#field-history`). Registers itself as the StateHost so all
`#history`, `#history/<id>`, `#backup`, `#import`). Legacy
`#field-history/<id>` URLs are normalized to `#history/<id>` on
`parseHash` (`vault.ts:139-173`); the internal view value stays
`'field-history'` so the per-item pane renders unchanged. Sidebar
bottom-nav: `+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history
· ⏻ lock`. Registers itself as the StateHost so all
`popup/components/*` renderers run unchanged. Maintains its own
`selectedItem` cache so hash navigation between already-loaded items
doesn't refetch.
- `vault.html` / `vault.css` — sidebar + pane layout.
### `src/vault/components/`
Vault-tab-only panes (popup is too small for these workflows). Each
exports `render…(app)` and a `teardown()`, same convention as
`popup/components/*`.
- `backup-panel.ts``.relbak` export / restore UI. Routable as
`#backup` (vault.ts case at :167). Drives the SW's backup handlers;
the actual tar packing happens in `relicario-core` via WASM exports.
- `import-panel.ts` — LastPass CSV importer surface. Routable as
`#import` (vault.ts case at :168). Parses CSV client-side and pipes
parsed rows through `add_item` SW messages.
### `src/setup/`
- `setup.ts` (1137 lines) — the wizard state machine. Six steps
@@ -829,3 +866,7 @@ still required before shipping.
next to `relicario_wasm_bg.wasm`. The runtime calls
`WebAssembly.instantiateStreaming(fetch(URL))` against a
hardcoded path; we just hand it that path.
---
**End of tour.** For roadmap and in-flight work see [../STATUS.md](../STATUS.md) and [../ROADMAP.md](../ROADMAP.md).

View File

@@ -1,6 +1,6 @@
{
"name": "relicario-extension",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"scripts": {
"build": "webpack --mode production",

View File

@@ -33,11 +33,16 @@ describe('devices view', () => {
vi.clearAllMocks();
});
// The component fires list_devices + list_revoked in parallel via Promise.all,
// so every render needs both mocked. Helper makes the per-test setup readable.
function mockListPair(devices: unknown[], revoked: unknown[] = []): void {
(sendMessage as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, data: { devices } })
.mockResolvedValueOnce({ ok: true, data: { revoked } });
}
it('renders empty state when no devices', async () => {
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: { devices: [] },
});
mockListPair([]);
await renderDevices(app);
@@ -45,15 +50,10 @@ describe('devices view', () => {
});
it('renders devices with "you" indicator on current device', async () => {
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: {
devices: [
{ name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 },
{ name: 'CLI', public_key: 'def', added_at: 500 },
],
},
});
mockListPair([
{ name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 },
{ name: 'CLI', public_key: 'def', added_at: 500 },
]);
await renderDevices(app);
@@ -68,23 +68,15 @@ describe('devices view', () => {
it('shows unregistered banner when current device not in list', async () => {
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: {
devices: [{ name: 'CLI', public_key: 'abc', added_at: 1000 }],
},
});
mockListPair([{ name: 'CLI', public_key: 'abc', added_at: 1000 }]);
await renderDevices(app);
expect(app.innerHTML).toContain('This device is not registered');
expect(app.innerHTML).toContain("This device isn't registered");
});
it('back button navigates to list', async () => {
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: { devices: [] },
});
mockListPair([]);
await renderDevices(app);
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
@@ -94,10 +86,7 @@ describe('devices view', () => {
it('clicking register button reveals an inline name input', async () => {
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] },
});
mockListPair([{ name: 'CLI', public_key: 'k', added_at: 1 }]);
await renderDevices(app);
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
@@ -108,13 +97,15 @@ describe('devices view', () => {
it('confirming register sends register_this_device with the entered name', async () => {
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
// Initial list_devices.
(sendMessage as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
// register_this_device.
.mockResolvedValueOnce({ ok: true })
// Re-render's list_devices.
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }, { name: 'Test Browser', public_key: 'q', added_at: 2 }] } });
// Initial render: list_devices + list_revoked.
mockListPair([{ name: 'CLI', public_key: 'k', added_at: 1 }]);
// register_this_device.
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
// Re-render: list_devices + list_revoked.
mockListPair([
{ name: 'CLI', public_key: 'k', added_at: 1 },
{ name: 'Test Browser', public_key: 'q', added_at: 2 },
]);
// Re-render also re-reads device_name from storage.
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Test Browser' });

View File

@@ -38,7 +38,7 @@ describe('field-history view', () => {
expect(app.innerHTML).toContain('No history available');
});
it('renders history entries masked by default', async () => {
it('renders history entries masked by default with section-header and glyph buttons', async () => {
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: {
@@ -53,9 +53,17 @@ describe('field-history view', () => {
await renderFieldHistory(app);
// Masked by default
expect(app.innerHTML).toContain('••••••••••••');
expect(app.innerHTML).not.toContain('secret123');
expect(app.innerHTML).toContain('current');
// Section-header per field with uppercase name + entry count
expect(app.innerHTML).toContain('section-header');
expect(app.innerHTML).toContain('PASSWORD · 2 entries');
// Current entry annotation
expect(app.innerHTML).toContain('current · ');
// Explicit glyph buttons (reveal + copy) on each entry
expect(app.querySelectorAll('[data-entry-reveal]').length).toBe(2);
expect(app.querySelectorAll('[data-entry-copy]').length).toBe(2);
});
it('back button navigates to detail', async () => {

View File

@@ -40,19 +40,30 @@ describe('settings-vault', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true });
// Default: get_session_config returns inactivity/15, everything else ok
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
if (msg.type === 'get_session_config') {
return { ok: true, data: { config: { mode: 'inactivity', minutes: 15 } } };
}
return { ok: true };
});
});
it('renders with seeded vault-settings values', () => {
it('renders with seeded vault-settings values', async () => {
const app = document.getElementById('app')!;
renderVaultSettings(app);
expect(app.textContent).toContain('vault settings');
// Initial synchronous render paints the vault settings section headers
expect(app.querySelector('.section-header')?.textContent).toContain('VAULT SETTINGS');
expect(app.textContent).toContain('github.com');
expect(app.textContent).toContain('example.com');
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
expect(trashSel.value).toBe('days:30');
const histSel = document.getElementById('history-retention') as HTMLSelectElement;
expect(histSel.value).toBe('forever');
// After get_session_config resolves, SESSION row appears
await new Promise((r) => setTimeout(r, 10));
expect(app.textContent).toContain('SESSION');
expect(app.textContent).toContain('after inactivity');
});
it('renders origin acks sorted by recency (descending)', () => {
@@ -70,7 +81,7 @@ describe('settings-vault', () => {
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
trashSel.value = 'forever';
trashSel.dispatchEvent(new Event('change', { bubbles: true }));
expect(saveBtn.disabled).toBe(false);
expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false);
});
it('revoke button removes origin from pending and enables save', () => {
@@ -95,4 +106,28 @@ describe('settings-vault', () => {
expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com');
expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com');
});
it('section headers render in correct order: VAULT SETTINGS, THIS DEVICE, ACTIONS', () => {
const app = document.getElementById('app')!;
renderVaultSettings(app);
const headers = Array.from(document.querySelectorAll('.section-header')).map((e) => e.textContent?.trim());
expect(headers[0]).toContain('VAULT SETTINGS');
expect(headers[1]).toContain('THIS DEVICE');
expect(headers[2]).toContain('ACTIONS');
});
it('subtitle shows "no changes" initially', () => {
const app = document.getElementById('app')!;
renderVaultSettings(app);
expect(app.querySelector('.settings-header__sub')?.textContent).toBe('no changes');
});
it('subtitle shows "unsaved · esc to cancel" after making a change', () => {
const app = document.getElementById('app')!;
renderVaultSettings(app);
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
trashSel.value = 'forever';
trashSel.dispatchEvent(new Event('change', { bubbles: true }));
expect(document.querySelector('.settings-header__sub')?.textContent).toContain('unsaved');
});
});

View File

@@ -24,18 +24,35 @@ function mockChromeStorage(initial: Record<string, unknown> = {}) {
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
},
local: {
get: vi.fn(() => Promise.resolve({})),
set: vi.fn(() => Promise.resolve()),
},
},
};
return store;
}
function settingsResponses() {
// Two parallel calls in renderSettings: get_settings + get_blacklist.
// After the Stream B left-nav restructure (bd6a301) and the management-surfaces
// revamp, renderSettings makes these calls in this order:
// 1. is_unlocked (gates vault-only sections)
// 2. get_settings + get_blacklist (parallel) (Autofill is the default section)
function mockDefaultLanding(opts: { unlocked?: boolean } = {}) {
const unlocked = opts.unlocked ?? false;
(sendMessage as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, data: { unlocked } })
.mockResolvedValueOnce({ ok: true, data: { settings: { captureEnabled: false, captureStyle: 'bar' } } })
.mockResolvedValueOnce({ ok: true, data: { blacklist: [] } });
}
async function navigateToDisplay(app: HTMLElement): Promise<void> {
const btn = app.querySelector<HTMLButtonElement>('[data-section="display"]')!;
btn.click();
// Allow renderDisplaySection's async loadColorScheme + DOM writes to settle.
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
}
describe('settings view', () => {
let app: HTMLElement;
@@ -45,42 +62,45 @@ describe('settings view', () => {
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
});
it('renders a Sync now button', async () => {
it('renders the left-nav with the seven sections', async () => {
mockChromeStorage();
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
expect(app.querySelector('#sync-now-btn')).not.toBeNull();
const sections = ['autofill', 'display', 'security', 'generator', 'retention', 'backup', 'import'];
for (const s of sections) {
expect(app.querySelector(`[data-section="${s}"]`)).not.toBeNull();
}
});
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
it('lands on the Autofill section by default and renders the capture toggle', async () => {
mockChromeStorage();
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
expect(sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' });
expect(sendMessage).toHaveBeenCalledWith({ type: 'get_settings' });
expect(sendMessage).toHaveBeenCalledWith({ type: 'get_blacklist' });
expect(app.querySelector<HTMLInputElement>('#capture-enabled')).not.toBeNull();
});
it('toggling capture-enabled sends an update_settings message', async () => {
mockChromeStorage();
mockDefaultLanding();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
await renderSettings(app);
app.querySelector<HTMLButtonElement>('#sync-now-btn')!.click();
await new Promise((r) => setTimeout(r, 0));
const cb = app.querySelector<HTMLInputElement>('#capture-enabled')!;
cb.checked = true;
cb.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
expect(sendMessage).toHaveBeenCalledWith({ type: 'sync' });
const status = app.querySelector('#sync-status')!;
expect(status.textContent).toMatch(/synced/i);
});
it('shows the error when sync fails', async () => {
mockChromeStorage();
settingsResponses();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
await renderSettings(app);
app.querySelector<HTMLButtonElement>('#sync-now-btn')!.click();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
const status = app.querySelector('#sync-status')!;
expect(status.textContent).toMatch(/remote_unreachable/);
expect(sendMessage).toHaveBeenCalledWith({
type: 'update_settings',
settings: { captureEnabled: true },
});
});
});
@@ -95,12 +115,13 @@ describe('settings Display section', () => {
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
mockChromeStorage();
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
await navigateToDisplay(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
const digitInput = app.querySelector<HTMLInputElement>('#digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color');
expect(digitInput).not.toBeNull();
expect(symbolInput).not.toBeNull();
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
@@ -111,32 +132,35 @@ describe('settings Display section', () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
await navigateToDisplay(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
const digitInput = app.querySelector<HTMLInputElement>('#digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color');
expect(digitInput!.value).toBe('#112233');
expect(symbolInput!.value).toBe('#aabbcc');
});
it('renders a color-preview-swatch element', async () => {
it('renders a color-preview swatch element', async () => {
mockChromeStorage();
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
await navigateToDisplay(app);
expect(app.querySelector('#display-swatch')).not.toBeNull();
expect(app.querySelector('#color-preview')).not.toBeNull();
});
it('changing digit color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
await navigateToDisplay(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
const digitInput = app.querySelector<HTMLInputElement>('#digit-color')!;
digitInput.value = '#ff0000';
digitInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
@@ -151,11 +175,12 @@ describe('settings Display section', () => {
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
await navigateToDisplay(app);
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color')!;
symbolInput.value = '#00ff00';
symbolInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
@@ -172,19 +197,21 @@ describe('settings Display section', () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
mockDefaultLanding();
await renderSettings(app);
await navigateToDisplay(app);
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
const resetBtn = app.querySelector<HTMLButtonElement>('#reset-colors')!;
resetBtn.click();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
const digitInput = app.querySelector<HTMLInputElement>('#digit-color')!;
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color')!;
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
});

View File

@@ -52,7 +52,8 @@ describe('trash view', () => {
await renderTrash(app);
expect(app.innerHTML).toContain('Test Login');
expect(app.innerHTML).toContain('restore');
expect(app.querySelector('[data-restore]')).not.toBeNull();
expect(app.innerHTML).toContain('purges in');
expect(app.querySelector('#empty-trash-btn')).not.toBeNull();
});

View File

@@ -2,6 +2,9 @@
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types';
import { relativeTime } from '../../shared/relative-time';
import { sshFingerprint } from '../../shared/ssh-fingerprint';
import { GLYPH_REVOKE } from '../../shared/glyphs';
interface RevokedEntry {
name: string;
@@ -10,16 +13,6 @@ interface RevokedEntry {
revoked_by: string;
}
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 2592000)}mo ago`;
}
function detectDefaultDeviceName(): string {
const ua = navigator.userAgent ?? '';
const platform = (navigator.platform ?? '').toLowerCase();
@@ -61,37 +54,47 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
// Precompute fingerprints for all active devices
const fingerprints = new Map<string, string>();
await Promise.all(devices.map(async (d) => {
const fp = await sshFingerprint(d.public_key);
fingerprints.set(d.name, fp ?? '(unknown)');
}));
const activeDevicesHtml = devices.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
: devices.map((d) => {
const isCurrentDevice = d.name === currentDeviceName;
const fp = fingerprints.get(d.name) ?? '(unknown)';
const addedBy = d.added_by && d.added_by !== 'unknown' ? ` · by ${escapeHtml(d.added_by)}` : '';
return `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
<div class="device-row" data-device="${escapeHtml(d.name)}">
<div class="device-row__head">
<span class="device-row__name">${escapeHtml(d.name)}</span>
${isCurrentDevice
? '<span class="device-row__you">← you</span>'
: `<button class="glyph-btn" data-danger data-revoke="${escapeHtml(d.name)}" title="revoke" aria-label="revoke ${escapeHtml(d.name)}">${GLYPH_REVOKE}</button>`}
</div>
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
<div class="fingerprint">${escapeHtml(fp)}</div>
<div class="device-row__meta">added ${escapeHtml(relativeTime(d.added_at))}${addedBy}</div>
<div class="device-row__confirm" data-confirm-for="${escapeHtml(d.name)}" hidden></div>
</div>
`;
}).join('');
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
<details class="revoked-section" style="margin-top:16px;">
<summary class="muted" style="cursor:pointer;font-size:0.85em;">
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
</summary>
<div style="margin-top:8px;">
<details class="revoked-section">
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
<div class="revoked-section__body">
${revokedDevices.map((r) => `
<div class="device-row device-row--revoked">
<div class="device-row__info">
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;">
<div class="device-row__head">
<span class="device-row__name" style="text-decoration:line-through;opacity:0.6;">
${escapeHtml(r.name)}
</span>
<span class="device-row__meta">
revoked ${relativeTime(r.revoked_at)}
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''}
</span>
</div>
<div class="device-row__meta">
revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''}
</div>
</div>
`).join('')}
@@ -107,11 +110,14 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
</div>
${!isRegistered ? `
<div class="device-banner">
<span>⚠ This device is not registered</span>
<div class="device-banner__title">This device isn't registered.</div>
<p class="device-banner__body muted">Registering generates an ed25519 keypair and adds the public key to <code>.relicario/devices.json</code> on the remote.</p>
<button class="btn btn-primary" id="register-btn">Register this device</button>
</div>
` : ''}
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
${activeDevicesHtml}
${revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : ''}
${revokedSectionHtml}
</div>
`;
@@ -160,20 +166,43 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
});
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.addEventListener('click', () => {
const name = btn.dataset.revoke;
if (!name) return;
if (!confirm(`Revoke ${name}? This device will no longer be authorized.`)) return;
const panel = document.querySelector<HTMLElement>(`[data-confirm-for="${CSS.escape(name)}"]`);
if (!panel) return;
panel.hidden = false;
panel.innerHTML = `
<p class="device-row__confirm-text">
Revoke this device? It won't be able to sign commits or push changes after revocation.
</p>
<div class="device-row__confirm-actions">
<button class="btn" data-revoke-cancel="${escapeHtml(name)}">cancel</button>
<button class="btn btn-danger" data-revoke-confirm="${escapeHtml(name)}">revoke</button>
</div>
`;
btn.disabled = true;
btn.textContent = '...';
const result = await sendMessage({ type: 'revoke_device', name });
if (result.ok) {
await sendMessage({ type: 'sync' });
renderDevices(app);
} else {
setState({ error: result.error });
}
panel.querySelector('[data-revoke-cancel]')?.addEventListener('click', () => {
panel.hidden = true;
panel.innerHTML = '';
btn.disabled = false;
});
panel.querySelector('[data-revoke-confirm]')?.addEventListener('click', async () => {
const confirmBtn = panel.querySelector<HTMLButtonElement>('[data-revoke-confirm]')!;
confirmBtn.disabled = true;
confirmBtn.textContent = '...';
const result = await sendMessage({ type: 'revoke_device', name });
if (result.ok) {
await sendMessage({ type: 'sync' });
renderDevices(app);
} else {
setState({ error: result.error });
confirmBtn.disabled = false;
confirmBtn.textContent = 'revoke';
}
});
});
});
}

View File

@@ -3,18 +3,8 @@
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import { colorizePassword } from '../../shared/password-coloring';
import type { FieldHistoryView } from '../../shared/types';
import { GLYPH_COPY } from '../../shared/glyphs';
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
return `${Math.floor(diff / 2592000)}mo ago`;
}
import { relativeTime } from '../../shared/relative-time';
import { GLYPH_COPY, GLYPH_REVEAL, GLYPH_HIDE } from '../../shared/glyphs';
const revealedSet = new Set<string>();
@@ -68,27 +58,28 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
const isRevealed = revealedSet.has(entryKey);
const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••';
valueStore.set(entryKey, value);
const revealGlyph = isRevealed ? GLYPH_HIDE : GLYPH_REVEAL;
return `
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
<div class="history-entry__meta">
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
<div class="history-entry__meta muted">
${isCurrent ? '<span class="history-entry__current">current · </span>' : ''}
${isCurrent ? 'set' : 'changed'} ${escapeHtml(relativeTime(timestamp))}
</div>
<div class="history-entry__actions">
<button class="glyph-btn" data-entry-reveal="${escapeHtml(entryKey)}" title="${isRevealed ? 'hide' : 'reveal'}" aria-label="${isRevealed ? 'hide' : 'reveal'}">${revealGlyph}</button>
<button class="glyph-btn" data-entry-copy="${escapeHtml(entryKey)}" title="copy" aria-label="copy">${GLYPH_COPY}</button>
</div>
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
</div>
`;
}
let content = '';
for (const field of history) {
if (history.length > 1) {
content += `<div class="history-field-label">${escapeHtml(field.field_name)}</div>`;
}
// Current value first
const entryCount = field.entries.length + 1; // +1 for current
content += `<div class="section-header">${escapeHtml(field.field_name.toUpperCase())} · ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}</div>`;
content += renderEntry(field.field_id, field.current_value, item.modified, true);
// Historical values
for (const entry of field.entries) {
content += renderEntry(field.field_id, entry.value, entry.changed_at, false);
}
@@ -118,17 +109,14 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
// Wire handlers
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
// Toggle reveal on click
app.querySelectorAll<HTMLElement>('.history-entry').forEach((el) => {
el.addEventListener('click', (e) => {
if ((e.target as HTMLElement).classList.contains('history-entry__copy')) return;
const key = el.dataset.entry;
// Reveal toggle via explicit glyph button (decoupled from row click)
app.querySelectorAll<HTMLButtonElement>('[data-entry-reveal]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.entryReveal;
if (!key) return;
if (revealedSet.has(key)) {
revealedSet.delete(key);
} else {
revealedSet.add(key);
}
if (revealedSet.has(key)) revealedSet.delete(key);
else revealedSet.add(key);
renderFieldHistory(app);
});
});

View File

@@ -0,0 +1,130 @@
/// History index — lists items that have any field history, sorted by most-recent
/// change. Clicking a row drills into the per-item view (field-history.ts).
///
/// Implementation: iterate manifest, fetch each item via get_field_history, check
/// for ≥1 non-empty history-tracked field, emit an entry per qualifying item.
import { getState, sendMessage, navigate, setState, escapeHtml } from '../../shared/state';
import type { Item, ItemId, ManifestEntry } from '../../shared/types';
import { relativeTime } from '../../shared/relative-time';
import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
} from '../../shared/glyphs';
const TYPE_ICONS: Record<string, string> = {
login: GLYPH_TYPE_LOGIN,
secure_note: GLYPH_TYPE_SECURE_NOTE,
identity: GLYPH_TYPE_IDENTITY,
card: GLYPH_TYPE_CARD,
key: GLYPH_TYPE_KEY,
document: GLYPH_TYPE_DOCUMENT,
totp: GLYPH_TYPE_TOTP,
};
interface HistoryIndexEntry {
id: ItemId;
type: string;
title: string;
changeCount: number;
lastChangedAt: number;
}
export function teardown(): void {
// No persistent state.
}
export async function renderItemHistoryIndex(app: HTMLElement): Promise<void> {
const state = getState();
const manifest: Array<[ItemId, ManifestEntry]> = state.entries ?? [];
app.innerHTML = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">history</h3>
</div>
<p class="muted" style="margin:8px 0;">Scanning items…</p>
</div>
`;
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
const entries: HistoryIndexEntry[] = [];
await Promise.all(manifest.map(async ([id, manifestEntry]) => {
if (manifestEntry.trashed_at !== undefined && manifestEntry.trashed_at !== null) return;
const resp = await sendMessage({ type: 'get_field_history', id });
if (!resp.ok) return;
const history = (resp.data as { history: Array<{ entries: Array<{ changed_at: number }> }> }).history;
let totalCount = 0;
let mostRecent = 0;
for (const field of history) {
totalCount += field.entries.length;
for (const e of field.entries) {
if (e.changed_at > mostRecent) mostRecent = e.changed_at;
}
}
if (totalCount > 0) {
entries.push({
id,
type: manifestEntry.type,
title: manifestEntry.title,
changeCount: totalCount,
lastChangedAt: mostRecent,
});
}
}));
entries.sort((a, b) => b.lastChangedAt - a.lastChangedAt);
if (entries.length === 0) {
app.innerHTML = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">history</h3>
</div>
<p class="muted" style="text-align:center;margin-top:32px;">
No field history yet.<br>
Edits to passwords, TOTP secrets, and concealed fields will appear here.
</p>
</div>
`;
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
return;
}
app.innerHTML = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">history</h3>
</div>
<p class="muted" style="margin:8px 0;">${entries.length} item${entries.length === 1 ? '' : 's'} have field history</p>
<div class="section-header">&nbsp;</div>
${entries.map((e) => `
<div class="history-index-row" data-id="${escapeHtml(e.id)}">
<span class="history-index-row__icon">${TYPE_ICONS[e.type] ?? '◻'}</span>
<div class="history-index-row__info">
<span class="history-index-row__title">${escapeHtml(e.title)}</span>
<span class="history-index-row__meta muted">${e.changeCount} change${e.changeCount === 1 ? '' : 's'} · last ${escapeHtml(relativeTime(e.lastChangedAt))}</span>
</div>
</div>
`).join('')}
</div>
`;
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
app.querySelectorAll<HTMLElement>('.history-index-row').forEach((row) => {
row.addEventListener('click', async () => {
const id = row.dataset.id as ItemId;
const itemResp = await sendMessage({ type: 'get_item', id });
if (!itemResp.ok) {
setState({ error: 'Failed to load item' });
return;
}
const item = (itemResp.data as { item: Item }).item;
setState({ selectedId: id, selectedItem: item, historyItemId: id });
navigate('field-history');
});
});
}

View File

@@ -6,11 +6,15 @@ import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } f
import type {
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
} from '../../shared/types';
import type { SessionTimeoutConfig } from '../../shared/messages';
import { relativeTime } from '../../shared/relative-time';
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
import { GLYPH_NEXT } from '../../shared/glyphs';
let pendingSettings: VaultSettings | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let pendingSession: SessionTimeoutConfig | null = null;
let baseSession: SessionTimeoutConfig | null = null;
export function teardown(): void {
closeGeneratorPanel();
@@ -19,6 +23,8 @@ export function teardown(): void {
activeKeyHandler = null;
}
pendingSettings = null;
pendingSession = null;
baseSession = null;
}
// --- Retention helpers ---
@@ -65,17 +71,6 @@ function generatorSummary(req: GeneratorRequest): string {
return `BIP39, ${req.word_count} words, "${req.separator}" separator, ${req.capitalization}`;
}
// --- Time formatting ---
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
// --- Render ---
export function renderVaultSettings(app: HTMLElement): void {
@@ -87,69 +82,79 @@ export function renderVaultSettings(app: HTMLElement): void {
}
pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings;
sendMessage({ type: 'get_session_config' }).then((resp) => {
// Guard against clobbering the user's in-flight edits if they tap a radio
// before the SW responds — tiny window but real.
if (resp.ok && !pendingSession) {
baseSession = (resp.data as { config: SessionTimeoutConfig }).config;
pendingSession = JSON.parse(JSON.stringify(baseSession)) as SessionTimeoutConfig;
rerender();
}
});
function rerender(): void {
if (!pendingSettings) return;
const acksEntries = Object.entries(pendingSettings.autofill_origin_acks)
.sort(([, a], [, b]) => b - a);
const dirty: boolean = JSON.stringify(pendingSettings) !== JSON.stringify(base)
|| !!(baseSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession));
const subtitle = dirty ? 'unsaved · esc to cancel' : 'no changes';
const sessionMode = pendingSession?.mode ?? 'inactivity';
const sessionMinutes = pendingSession && pendingSession.mode === 'inactivity'
? pendingSession.minutes : 15;
app.innerHTML = `
<div class="pad">
<div class="settings-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">vault settings</h3>
<h3 style="margin:0;">settings</h3>
<span class="muted settings-header__sub">${escapeHtml(subtitle)}</span>
</div>
<div class="settings-section">
<div class="settings-section__title">retention</div>
<div class="settings-row">
<span class="settings-row__label">trash</span>
<select id="trash-retention">
<option value="forever">Forever</option>
<option value="days:7">7 days</option>
<option value="days:30">30 days</option>
<option value="days:60">60 days</option>
<option value="days:90">90 days</option>
<option value="days:180">180 days</option>
<option value="days:365">365 days</option>
</select>
<div class="section-header">VAULT SETTINGS · synced</div>
<div class="settings-grid">
<div class="settings-section">
<div class="settings-section__title">RETENTION</div>
<div class="settings-row">
<span class="settings-row__label">trash</span>
<select id="trash-retention">
<option value="forever">Forever</option>
<option value="days:7">7 days</option>
<option value="days:30">30 days</option>
<option value="days:60">60 days</option>
<option value="days:90">90 days</option>
<option value="days:180">180 days</option>
<option value="days:365">365 days</option>
</select>
</div>
<div class="settings-row">
<span class="settings-row__label">history</span>
<select id="history-retention">
<option value="forever">Forever</option>
<option value="last_n:3">Last 3</option>
<option value="last_n:5">Last 5</option>
<option value="last_n:10">Last 10</option>
<option value="days:30">30 days</option>
<option value="days:90">90 days</option>
<option value="days:365">365 days</option>
</select>
</div>
</div>
<div class="settings-row">
<span class="settings-row__label">field history</span>
<select id="history-retention">
<option value="forever">Forever</option>
<option value="last_n:3">Last 3</option>
<option value="last_n:5">Last 5</option>
<option value="last_n:10">Last 10</option>
<option value="days:30">30 days</option>
<option value="days:90">90 days</option>
<option value="days:365">365 days</option>
</select>
<div class="settings-section">
<div class="settings-section__title">GENERATOR</div>
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨ configure</button>
</div>
</div>
<div class="settings-section">
<div class="settings-section__title">generator</div>
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨</button>
</div>
<div class="settings-section">
<div class="settings-section__title">autofill origins</div>
${acksEntries.length === 0
? `<p class="muted">No origins acknowledged yet.</p>`
: acksEntries.map(([host, ts]) => `
<div class="ack-row">
<span class="ack-row__host">${escapeHtml(host)}</span>
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
<button class="ack-row__revoke" data-revoke="${escapeHtml(host)}">revoke</button>
</div>
`).join('')}
</div>
<div class="settings-section">
<div class="settings-section__title">attachments</div>
<div class="settings-section__title">ATTACHMENTS</div>
<div class="settings-row">
<span class="settings-row__label">max file size</span>
<span class="settings-row__label">max size</span>
<select id="attachment-cap">
<option value="5242880">5 MB</option>
<option value="10485760">10 MB</option>
@@ -160,43 +165,61 @@ export function renderVaultSettings(app: HTMLElement): void {
</div>
<div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div>
<div class="settings-section__title">AUTOFILL ORIGINS</div>
${acksEntries.length === 0
? `<p class="muted">No origins acknowledged yet.</p>`
: acksEntries.map(([host, ts]) => `
<div class="ack-row">
<span class="ack-row__host">${escapeHtml(host)}</span>
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
<button class="glyph-btn" data-danger title="revoke" data-revoke="${escapeHtml(host)}">⊘</button>
</div>
`).join('')}
</div>
<div class="section-header">THIS DEVICE · local</div>
<div class="settings-section">
<div class="settings-section__title">SESSION</div>
<div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore ${GLYPH_NEXT}</button>
<label><input type="radio" name="session-mode" value="every_time" ${sessionMode === 'every_time' ? 'checked' : ''}> lock every time</label>
</div>
<div class="settings-row">
<label><input type="radio" name="session-mode" value="inactivity" ${sessionMode === 'inactivity' ? 'checked' : ''}> after inactivity</label>
<select id="session-minutes" ${sessionMode !== 'inactivity' ? 'disabled' : ''}>
<option value="5">5 min</option>
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">60 min</option>
</select>
</div>
</div>
<div class="section-header">ACTIONS</div>
<div class="settings-section">
<div class="settings-section__title">import</div>
<div class="settings-row">
<button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button>
<button class="btn" id="open-backup">Backup &amp; restore ${GLYPH_NEXT}</button>
<button class="btn" id="open-import">Import from… ${GLYPH_NEXT}</button>
</div>
</div>
<div class="settings-footer">
<button class="btn" id="discard-btn">discard</button>
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
<button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>save changes</button>
</div>
</div>
`;
// Set current select values
(document.getElementById('trash-retention') as HTMLSelectElement).value =
trashRetentionToValue(pendingSettings.trash_retention);
(document.getElementById('history-retention') as HTMLSelectElement).value =
historyRetentionToValue(pendingSettings.field_history_retention);
const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760;
(document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue);
(document.getElementById('session-minutes') as HTMLSelectElement).value = String(sessionMinutes);
wireHandlers();
updateSaveEnabled();
}
function updateSaveEnabled(): void {
const saveBtn = document.getElementById('save-btn') as HTMLButtonElement | null;
if (!saveBtn || !pendingSettings || !base) return;
const changed = JSON.stringify(pendingSettings) !== JSON.stringify(base);
saveBtn.disabled = !changed;
}
function wireHandlers(): void {
@@ -208,13 +231,13 @@ export function renderVaultSettings(app: HTMLElement): void {
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
if (!pendingSettings) return;
pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
updateSaveEnabled();
rerender();
});
document.getElementById('history-retention')?.addEventListener('change', (e) => {
if (!pendingSettings) return;
pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
updateSaveEnabled();
rerender();
});
document.getElementById('attachment-cap')?.addEventListener('change', (e) => {
@@ -224,7 +247,7 @@ export function renderVaultSettings(app: HTMLElement): void {
...pendingSettings.attachment_caps,
per_attachment_max_bytes: bytes,
};
updateSaveEnabled();
rerender();
});
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
@@ -255,18 +278,46 @@ export function renderVaultSettings(app: HTMLElement): void {
document.getElementById('save-btn')?.addEventListener('click', async () => {
if (!pendingSettings) return;
const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings });
if (resp.ok) {
// Refresh cached state and navigate back.
const refreshed = await sendMessage({ type: 'get_vault_settings' });
if (refreshed.ok && refreshed.data) {
const vs = (refreshed.data as { settings: VaultSettings }).settings;
if (vs) {
setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults });
}
}
navigate('list');
} else {
if (!resp.ok) {
setState({ error: resp.error });
return;
}
if (pendingSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession)) {
const sessResp = await sendMessage({ type: 'update_session_config', config: pendingSession });
if (!sessResp.ok) {
setState({ error: sessResp.error });
return;
}
baseSession = JSON.parse(JSON.stringify(pendingSession)) as SessionTimeoutConfig;
}
const refreshed = await sendMessage({ type: 'get_vault_settings' });
if (refreshed.ok && refreshed.data) {
const vs = (refreshed.data as { settings: VaultSettings }).settings;
if (vs) {
setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults });
}
}
navigate('list');
});
document.querySelectorAll<HTMLInputElement>('input[name="session-mode"]').forEach((el) => {
el.addEventListener('change', () => {
const mode = (document.querySelector<HTMLInputElement>('input[name="session-mode"]:checked')?.value ?? 'inactivity') as 'every_time' | 'inactivity';
if (mode === 'every_time') {
pendingSession = { mode: 'every_time' };
} else {
const mins = Number((document.getElementById('session-minutes') as HTMLSelectElement).value);
pendingSession = { mode: 'inactivity', minutes: mins };
}
rerender();
});
});
document.getElementById('session-minutes')?.addEventListener('change', (e) => {
const mins = Number((e.target as HTMLSelectElement).value);
if (pendingSession?.mode === 'inactivity') {
pendingSession = { mode: 'inactivity', minutes: mins };
rerender();
}
});
}

View File

@@ -2,9 +2,11 @@
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
import { relativeTime, daysUntilPurge } from '../../shared/relative-time';
import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
GLYPH_RESTORE,
} from '../../shared/glyphs';
const TYPE_ICONS: Record<string, string> = {
@@ -17,21 +19,6 @@ const TYPE_ICONS: Record<string, string> = {
totp: GLYPH_TYPE_TOTP,
};
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function daysUntilPurge(trashedAt: number, retention: VaultSettings['trash_retention']): number | null {
if (retention.kind === 'forever') return null;
const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400);
return Math.max(0, retention.value - trashedDaysAgo);
}
export function teardown(): void {
// No cleanup needed
}
@@ -39,7 +26,6 @@ export function teardown(): void {
export async function renderTrash(app: HTMLElement): Promise<void> {
const state = getState();
// Fetch trashed items
const resp = await sendMessage({ type: 'list_trashed' });
if (!resp.ok) {
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
@@ -49,7 +35,6 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items;
const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 };
// Calculate days until oldest auto-purges
let oldestPurgeDays: number | null = null;
if (items.length > 0 && retention.kind === 'days') {
const oldest = items[items.length - 1][1];
@@ -59,8 +44,8 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
const headerInfo = items.length === 0
? ''
: oldestPurgeDays !== null
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest auto-purges in ${oldestPurgeDays}d`
: `${items.length} item${items.length === 1 ? '' : 's'}`;
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest purges in ${oldestPurgeDays} days`
: `${items.length} item${items.length === 1 ? '' : 's'} · retained forever`;
app.innerHTML = `
<div class="pad">
@@ -71,25 +56,30 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
${items.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
: items.map(([id, entry]) => `
<div class="trash-row" data-id="${escapeHtml(id)}">
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
<div class="trash-row__info">
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
</div>
<button class="trash-row__restore" data-restore="${escapeHtml(id)}">restore</button>
</div>
`).join('')}
: `<div class="section-header">&nbsp;</div>
${items.map(([id, entry]) => {
const purgeIn = daysUntilPurge(entry.trashed_at ?? 0, retention);
const purgeStr = purgeIn === null ? 'retained forever' : `purges in ${purgeIn} days`;
return `
<div class="trash-row" data-id="${escapeHtml(id)}">
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
<div class="trash-row__info">
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
<span class="trash-row__meta muted">trashed ${escapeHtml(relativeTime(entry.trashed_at ?? 0))} · ${escapeHtml(purgeStr)}</span>
</div>
<button class="glyph-btn" data-restore="${escapeHtml(id)}" title="restore" aria-label="restore ${escapeHtml(entry.title)}">${GLYPH_RESTORE}</button>
</div>
`;
}).join('')}`
}
${items.length > 0 ? `
<div style="margin-top:16px;text-align:center;">
<button class="btn danger" id="empty-trash-btn">empty trash</button>
<div class="trash-footer">
<button class="btn btn-danger" id="empty-trash-btn">empty trash</button>
</div>
` : ''}
</div>
`;
// Wire handlers
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((btn) => {
@@ -104,14 +94,14 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
renderTrash(app);
} else {
setState({ error: result.error });
btn.disabled = false;
btn.textContent = '⤺';
}
});
});
document.getElementById('empty-trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) {
return;
}
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) return;
const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement;
btn.disabled = true;
btn.textContent = 'deleting...';
@@ -121,6 +111,8 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
renderTrash(app);
} else {
setState({ error: result.error });
btn.disabled = false;
btn.textContent = 'empty trash';
}
});
}

View File

@@ -631,16 +631,6 @@ textarea {
.sig-block--red { border-left-color: #ab2b20; }
/* --- custom-section rendering (β₂ slice 1) --- */
.section-header {
margin-top: 14px;
margin-bottom: 4px;
padding-top: 10px;
border-top: 1px solid #21262d;
color: #8b949e;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-separator {
margin: 10px 0 4px;
border: 0;
@@ -1120,54 +1110,18 @@ textarea {
.trash-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.trash-row__icon {
font-size: 16px;
flex-shrink: 0;
}
.trash-row__info {
flex: 1;
min-width: 0;
}
.trash-row__title {
display: block;
font-size: 13px;
color: #c9d1d9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trash-row__meta {
font-size: 11px;
color: #8b949e;
}
.trash-row__restore {
font-size: 11px;
padding: 4px 8px;
background: #238636;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.trash-row__restore:hover {
background: #2ea043;
}
.trash-row__restore:disabled {
opacity: 0.5;
cursor: default;
.trash-row__icon { font-size: 14px; }
.trash-row__info { flex: 1; display: flex; flex-direction: column; }
.trash-row__title { color: var(--text); }
.trash-row__meta { font-size: 11px; color: var(--text-muted); }
.trash-footer {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* --- Devices view --- */
@@ -1179,69 +1133,47 @@ textarea {
margin-bottom: 12px;
}
.device-banner {
.device-row {
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.device-row__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
background: #3d1f00;
border: 1px solid #9e6a03;
border-radius: 4px;
margin-bottom: 12px;
font-size: 12px;
color: #f0c674;
margin-bottom: 2px;
}
.device-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
}
.device-row__info {
flex: 1;
min-width: 0;
}
.device-row__name {
display: block;
font-size: 13px;
color: #c9d1d9;
}
.device-row__name { color: var(--text); }
.device-row__you {
font-size: 11px;
color: #58a6ff;
color: var(--text-muted);
margin-left: 8px;
}
.device-row__meta {
font-size: 11px;
color: #8b949e;
color: var(--text-muted);
margin-top: 2px;
}
.device-row__confirm {
margin-top: 8px;
padding: 10px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--bg-input);
}
.device-row__confirm-text { margin: 0 0 8px 0; color: var(--text); }
.device-row__confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
.device-row__revoke {
font-size: 11px;
padding: 4px 8px;
background: #da3633;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.device-row__revoke:hover {
background: #f85149;
}
.device-row__revoke:disabled {
opacity: 0.5;
cursor: default;
.device-banner {
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--bg-pane);
margin-bottom: 16px;
}
.device-banner__title { margin-bottom: 4px; }
.device-banner__body { font-size: 12px; margin: 0 0 10px 0; }
/* --- Field history view --- */
@@ -1259,66 +1191,28 @@ textarea {
margin-bottom: 12px;
}
.history-field-label {
font-size: 11px;
color: #8b949e;
text-transform: uppercase;
margin: 12px 0 6px;
}
.history-entry {
display: flex;
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
align-items: center;
gap: 8px;
padding: 10px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
cursor: pointer;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.history-entry:hover {
background: #1c2128;
}
.history-entry__value {
flex: 1;
font-family: monospace;
font-size: 13px;
font-family: ui-monospace, monospace;
word-break: break-all;
}
.history-entry__value.masked {
color: #8b949e;
}
.history-entry__value.revealed {
color: #c9d1d9;
}
.history-entry__value.masked { letter-spacing: 1px; }
.history-entry__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
grid-column: 1 / 2;
font-size: 11px;
color: #8b949e;
}
.history-entry__current {
color: #58a6ff;
font-weight: 500;
}
.history-entry__copy {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 4px;
}
.history-entry__copy:hover {
opacity: 0.8;
.history-entry__actions {
grid-row: 1 / 3;
grid-column: 2 / 3;
display: flex;
gap: 4px;
}
/* --- Type selection --- */
@@ -1700,6 +1594,40 @@ textarea {
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }
/* --- Shared utility classes for management surfaces (settings/devices/trash/history) --- */
.section-header {
text-transform: uppercase;
font-weight: 500;
letter-spacing: 1px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 4px;
margin: 16px 0 10px 0;
font-size: 11px;
}
.section-header:first-child { margin-top: 0; }
.glyph-btn[data-danger]:hover { color: var(--danger); border-color: var(--danger); }
.kv-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 4px 0;
}
.kv-row > .k { color: var(--text-muted); }
.kv-row > .v { color: var(--text); font-variant-numeric: tabular-nums; }
.fingerprint {
font-family: ui-monospace, monospace;
color: var(--text-muted);
font-size: 11px;
word-break: break-all; /* wraps to two lines in popup (~360px) */
line-height: 1.4;
}
/* === Settings layout === */
.settings-layout {
display: flex;
@@ -1788,3 +1716,32 @@ textarea {
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
.setting-card__actions { display: flex; gap: 8px; }
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 720px) {
.settings-grid { grid-template-columns: 1fr; }
}
.settings-header__sub {
margin-left: auto;
font-size: 11px;
}
.history-index-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
}
.history-index-row:hover { background: var(--bg-input); }
.history-index-row__icon { font-size: 14px; }
.history-index-row__info { flex: 1; display: flex; flex-direction: column; }
.history-index-row__title { color: var(--text); }
.history-index-row__meta { font-size: 11px; }

View File

@@ -3,10 +3,19 @@ import { readDevices, addDevice, revokeDevice } from '../devices';
import type { GitHost } from '../git-host';
function makeGitHost(devicesJson = '{"devices":[]}'): GitHost {
let stored = devicesJson;
// Per-path storage — revokeDevice writes devices.json AND revoked.json,
// so a single slot would corrupt the second read.
const files = new Map<string, string>();
files.set('.relicario/devices.json', devicesJson);
return {
readFile: vi.fn().mockImplementation(async () => new TextEncoder().encode(stored)),
writeFile: vi.fn().mockImplementation(async (_p, bytes) => { stored = new TextDecoder().decode(bytes); }),
readFile: vi.fn().mockImplementation(async (path: string) => {
const content = files.get(path);
if (content === undefined) throw new Error(`404: ${path}`);
return new TextEncoder().encode(content);
}),
writeFile: vi.fn().mockImplementation(async (path: string, bytes: Uint8Array) => {
files.set(path, new TextDecoder().decode(bytes));
}),
deleteFile: vi.fn(),
listDir: vi.fn(),
putBlob: vi.fn(),

View File

@@ -378,18 +378,18 @@ describe('setup tab exception scope', () => {
// --- register_this_device: wasm returns a JS object, not a JSON string ---
//
// The #[wasm_bindgen] binding for `generate_device_keypair` uses
// `serde-wasm-bindgen` and returns a plain JsValue (object), not a JSON
// string. Calling JSON.parse on it throws `SyntaxError: "[object Object]"
// is not valid JSON`. This regression test pins the contract.
// The #[wasm_bindgen] binding for `register_device` uses `serde-wasm-bindgen`
// and returns a plain JsValue (object), not a JSON string. Calling
// JSON.parse on it would throw `SyntaxError: "[object Object]" is not
// valid JSON`. This regression test pins that contract.
describe('register_this_device', () => {
it('treats generate_device_keypair() as an object, not a JSON string', async () => {
it('treats register_device() return value as an object, not a JSON string', async () => {
const state = makeState();
state.gitHost = {} as never;
state.wasm.generate_device_keypair = () => ({
public_key_hex: 'aa'.repeat(32),
private_key_base64: 'AAAA',
state.wasm.register_device = () => ({
signing_public_key: 'aa'.repeat(32),
deploy_public_key: 'bb'.repeat(32),
});
vi.mocked(devices.addDevice).mockClear();

View File

@@ -68,3 +68,17 @@ describe('Stream A glyphs (vault tab + type icons)', () => {
}
});
});
describe('management-surface glyphs', () => {
it('exposes a history glyph', () => {
expect(glyphs.GLYPH_HISTORY).toBe('◷');
});
it('exposes a revoke glyph distinct from reveal/hide semantics', () => {
expect(glyphs.GLYPH_REVOKE).toBe('⊘');
});
it('exposes a restore glyph for trash actions', () => {
expect(glyphs.GLYPH_RESTORE).toBe('⤺');
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { relativeTime, daysUntilPurge } from '../relative-time';
const NOW_UNIX = 1779552000; // fixed reference instant
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW_UNIX * 1000));
});
afterEach(() => {
vi.useRealTimers();
});
describe('relativeTime', () => {
it('returns "just now" under 60s', () => {
expect(relativeTime(NOW_UNIX - 30)).toBe('just now');
});
it('returns minutes under an hour', () => {
expect(relativeTime(NOW_UNIX - 600)).toBe('10m ago');
});
it('returns hours under a day', () => {
expect(relativeTime(NOW_UNIX - 7200)).toBe('2h ago');
});
it('returns days under a week', () => {
expect(relativeTime(NOW_UNIX - 3 * 86400)).toBe('3d ago');
});
it('returns weeks under a month', () => {
expect(relativeTime(NOW_UNIX - 14 * 86400)).toBe('2w ago');
});
it('returns months above 30 days', () => {
expect(relativeTime(NOW_UNIX - 90 * 86400)).toBe('3mo ago');
});
});
describe('daysUntilPurge', () => {
it('returns null for forever retention', () => {
expect(daysUntilPurge(NOW_UNIX - 5 * 86400, { kind: 'forever' })).toBeNull();
});
it('returns remaining days for a recent trash', () => {
expect(daysUntilPurge(NOW_UNIX - 8 * 86400, { kind: 'days', value: 30 })).toBe(22);
});
it('clamps to zero when retention already elapsed', () => {
expect(daysUntilPurge(NOW_UNIX - 60 * 86400, { kind: 'days', value: 30 })).toBe(0);
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { sshFingerprint } from '../ssh-fingerprint';
describe('sshFingerprint', () => {
it('formats a known ed25519 key to SHA256:<b64>', async () => {
// Public key for the seed below — same format `relicario device list` prints.
// Pre-computed: SHA256 of the base64-decoded key blob, base64-no-pad encoded.
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ alice@example';
const fp = await sshFingerprint(key);
expect(fp).toMatch(/^SHA256:[A-Za-z0-9+/]+$/);
expect(fp?.includes('=')).toBe(false);
});
it('returns null for malformed input', async () => {
expect(await sshFingerprint('')).toBeNull();
expect(await sshFingerprint('not a key')).toBeNull();
expect(await sshFingerprint('ssh-ed25519')).toBeNull(); // missing blob
});
it('returns null for invalid base64', async () => {
expect(await sshFingerprint('ssh-ed25519 !!!notbase64!!!')).toBeNull();
});
it('is deterministic for the same key', async () => {
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ';
const a = await sshFingerprint(key);
const b = await sshFingerprint(key);
expect(a).toBe(b);
});
});

View File

@@ -22,6 +22,9 @@ export const GLYPH_SYNC = '⇅'; // sync / upload
export const GLYPH_PREVIEW = '⊕'; // preview / expand
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
export const GLYPH_HISTORY = '◷'; // sidebar history nav (clock-quadrant — distinct from clock emoji)
export const GLYPH_REVOKE = '⊘'; // revoke device / autofill-origin ack (same shape as HIDE; kept distinct for semantic clarity)
export const GLYPH_RESTORE = '⤺'; // restore from trash
export const GLYPH_TYPE_LOGIN = '◉'; // login
export const GLYPH_TYPE_SECURE_NOTE = '◫'; // secure note

View File

@@ -0,0 +1,27 @@
/// Single source of truth for relative-time formatting and trash-retention math.
/// Pulled out of five near-duplicate inline copies (settings-vault, devices,
/// trash, field-history, vault/vault).
import type { TrashRetention } from './types';
/// Format a past unix timestamp (seconds) as "Nm ago" / "Nh ago" / "Nd ago" /
/// "Nw ago" / "Nmo ago" relative to now. Returns "just now" under 60 seconds.
export function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
return `${Math.floor(diff / 2592000)}mo ago`;
}
/// Days remaining until an item trashed at `trashedAt` (unix seconds) will be
/// auto-purged given the vault's retention policy. Returns null for forever
/// retention; clamps to 0 when the retention window has already elapsed.
export function daysUntilPurge(trashedAt: number, retention: TrashRetention): number | null {
if (retention.kind === 'forever') return null;
const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400);
return Math.max(0, retention.value - trashedDaysAgo);
}

View File

@@ -0,0 +1,32 @@
/// SSH-style SHA256 fingerprint of an ed25519 public key, computed in the
/// extension so devices.ts can display verifiable IDs without a SW round-trip.
/// Output format matches `ssh-keygen -lf` and `relicario device list`:
/// SHA256:<base64-no-pad of SHA256(decoded-key-blob)>
function base64Decode(b64: string): Uint8Array | null {
try {
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;
} catch {
return null;
}
}
function base64Encode(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
export async function sshFingerprint(publicKey: string): Promise<string | null> {
if (!publicKey) return null;
const parts = publicKey.trim().split(/\s+/);
if (parts.length < 2) return null; // need "<algo> <blob>"
const blob = base64Decode(parts[1]);
if (!blob || blob.length === 0) return null;
const hash = await crypto.subtle.digest('SHA-256', blob.buffer as ArrayBuffer);
const b64 = base64Encode(new Uint8Array(hash)).replace(/=+$/, '');
return `SHA256:${b64}`;
}

View File

@@ -150,6 +150,7 @@ export interface Device {
name: string;
public_key: string; // hex-encoded ed25519 pubkey
added_at: number; // unix timestamp
added_by?: string; // device name that registered this device (optional)
}
// --- Field history view ---

View File

@@ -550,16 +550,6 @@ textarea {
.sig-block--red { border-left-color: #ab2b20; }
/* --- custom-section rendering --- */
.section-header {
margin-top: 14px;
margin-bottom: 4px;
padding-top: 10px;
border-top: 1px solid #21262d;
color: #8b949e;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-separator {
margin: 10px 0 4px;
border: 0;
@@ -1040,54 +1030,18 @@ textarea {
.trash-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.trash-row__icon {
font-size: 16px;
flex-shrink: 0;
}
.trash-row__info {
flex: 1;
min-width: 0;
}
.trash-row__title {
display: block;
font-size: 13px;
color: #c9d1d9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trash-row__meta {
font-size: 11px;
color: #8b949e;
}
.trash-row__restore {
font-size: 11px;
padding: 4px 8px;
background: #238636;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.trash-row__restore:hover {
background: #2ea043;
}
.trash-row__restore:disabled {
opacity: 0.5;
cursor: default;
.trash-row__icon { font-size: 14px; }
.trash-row__info { flex: 1; display: flex; flex-direction: column; }
.trash-row__title { color: var(--text); }
.trash-row__meta { font-size: 11px; color: var(--text-muted); }
.trash-footer {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* --- Devices view --- */
@@ -1099,69 +1053,47 @@ textarea {
margin-bottom: 12px;
}
.device-banner {
.device-row {
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.device-row__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
background: #3d1f00;
border: 1px solid #9e6a03;
border-radius: 4px;
margin-bottom: 12px;
font-size: 12px;
color: #f0c674;
margin-bottom: 2px;
}
.device-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
}
.device-row__info {
flex: 1;
min-width: 0;
}
.device-row__name {
display: block;
font-size: 13px;
color: #c9d1d9;
}
.device-row__name { color: var(--text); }
.device-row__you {
font-size: 11px;
color: #58a6ff;
color: var(--text-muted);
margin-left: 8px;
}
.device-row__meta {
font-size: 11px;
color: #8b949e;
color: var(--text-muted);
margin-top: 2px;
}
.device-row__confirm {
margin-top: 8px;
padding: 10px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--bg-input);
}
.device-row__confirm-text { margin: 0 0 8px 0; color: var(--text); }
.device-row__confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
.device-row__revoke {
font-size: 11px;
padding: 4px 8px;
background: #da3633;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.device-row__revoke:hover {
background: #f85149;
}
.device-row__revoke:disabled {
opacity: 0.5;
cursor: default;
.device-banner {
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--bg-pane);
margin-bottom: 16px;
}
.device-banner__title { margin-bottom: 4px; }
.device-banner__body { font-size: 12px; margin: 0 0 10px 0; }
/* --- Field history view --- */
@@ -1179,66 +1111,28 @@ textarea {
margin-bottom: 12px;
}
.history-field-label {
font-size: 11px;
color: #8b949e;
text-transform: uppercase;
margin: 12px 0 6px;
}
.history-entry {
display: flex;
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
align-items: center;
gap: 8px;
padding: 10px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
cursor: pointer;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.history-entry:hover {
background: #1c2128;
}
.history-entry__value {
flex: 1;
font-family: monospace;
font-size: 13px;
font-family: ui-monospace, monospace;
word-break: break-all;
}
.history-entry__value.masked {
color: #8b949e;
}
.history-entry__value.revealed {
color: #c9d1d9;
}
.history-entry__value.masked { letter-spacing: 1px; }
.history-entry__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
grid-column: 1 / 2;
font-size: 11px;
color: #8b949e;
}
.history-entry__current {
color: #58a6ff;
font-weight: 500;
}
.history-entry__copy {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 4px;
}
.history-entry__copy:hover {
opacity: 0.8;
.history-entry__actions {
grid-row: 1 / 3;
grid-column: 2 / 3;
display: flex;
gap: 4px;
}
/* --- Type selection --- */
@@ -2156,3 +2050,66 @@ textarea {
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
/* --- Shared utility classes for management surfaces (settings/devices/trash/history) --- */
.section-header {
text-transform: uppercase;
font-weight: 500;
letter-spacing: 1px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 4px;
margin: 16px 0 10px 0;
font-size: 11px;
}
.section-header:first-child { margin-top: 0; }
.glyph-btn[data-danger]:hover { color: var(--danger); border-color: var(--danger); }
.kv-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 4px 0;
}
.kv-row > .k { color: var(--text-muted); }
.kv-row > .v { color: var(--text); font-variant-numeric: tabular-nums; }
.fingerprint {
font-family: ui-monospace, monospace;
color: var(--text-muted);
font-size: 11px;
word-break: break-all; /* wraps to two lines in popup (~360px) */
line-height: 1.4;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 720px) {
.settings-grid { grid-template-columns: 1fr; }
}
.settings-header__sub {
margin-left: auto;
font-size: 11px;
}
.history-index-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
}
.history-index-row:hover { background: var(--bg-input); }
.history-index-row__icon { font-size: 14px; }
.history-index-row__info { flex: 1; display: flex; flex-direction: column; }
.history-index-row__title { color: var(--text); }
.history-index-row__meta { font-size: 11px; }

View File

@@ -10,8 +10,9 @@ import type {
} from '../shared/types';
import { registerHost } from '../shared/state';
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
import { relativeTime } from '../shared/relative-time';
import {
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK,
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_HISTORY,
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../shared/glyphs';
@@ -22,6 +23,7 @@ import { renderDevices, teardown as teardownDevices } from '../popup/components/
import { renderSettings, teardownSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
import { applyColorScheme } from '../shared/color-scheme';
@@ -122,19 +124,11 @@ function typeLabel(t: ItemType): string {
return labels[t];
}
function relativeTime(unixSec: number): string {
const diffS = Math.floor(Date.now() / 1000) - unixSec;
if (diffS < 60) return 'just now';
if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`;
if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`;
return `${Math.floor(diffS / 86400)}d ago`;
}
// ---------------------------------------------------------------------------
// Hash routing
// ---------------------------------------------------------------------------
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup' | 'import';
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import';
interface HashRoute {
view: VaultView;
@@ -143,9 +137,15 @@ interface HashRoute {
}
function parseHash(): HashRoute {
const raw = window.location.hash.replace(/^#\/?/, '');
let raw = window.location.hash.replace(/^#\/?/, '');
if (!raw) return { view: 'list' };
// Normalize legacy bookmarks: #field-history/<id> → #history/<id>
if (raw.startsWith('field-history/')) {
raw = 'history/' + raw.slice('field-history/'.length);
window.location.hash = raw;
}
const parts = raw.split('/');
const view = parts[0] as VaultView;
@@ -155,6 +155,10 @@ function parseHash(): HashRoute {
return { view, id: parts[1] };
case 'add':
return { view, type: parts[1] };
case 'history':
return parts[1]
? { view: 'field-history', id: parts[1] }
: { view: 'history' };
case 'trash':
case 'devices':
case 'settings':
@@ -266,6 +270,7 @@ function render(): void {
function renderLockScreen(app: HTMLElement): void {
app.innerHTML = `
<div class="vault-lock-screen">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<span class="brand">Relicario</span>
<div class="vault-lock-screen__form">
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
@@ -324,6 +329,7 @@ function renderShell(app: HTMLElement): void {
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
<button class="vault-sidebar__nav-item" data-nav="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</span></button>
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
</div>
</div>
@@ -587,7 +593,7 @@ function wireSidebar(): void {
openTypePanel();
return;
}
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') {
state.selectedId = null;
state.selectedItem = null;
state.newType = null;
@@ -815,6 +821,7 @@ function teardownPaneComponents(): void {
teardownDevices();
teardownSettings();
teardownFieldHistory();
teardownHistoryIndex();
teardownBackup();
teardownImport();
}
@@ -872,6 +879,9 @@ function renderPane(): void {
case 'field-history':
renderFieldHistory(pane);
break;
case 'history':
renderItemHistoryIndex(pane);
break;
case 'backup':
renderBackupPanel(pane);
break;