Compare commits
206 Commits
feature/ar
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74cee8ac67 | ||
|
|
9b38aac188 | ||
|
|
c3044ed5af | ||
|
|
59ebc28e7e | ||
|
|
df488a3d7c | ||
|
|
2fa4d6824c | ||
|
|
783e3493f0 | ||
|
|
4cca9b465c | ||
|
|
5be3043ab5 | ||
|
|
cf89bf8ca4 | ||
|
|
a91ceea0ed | ||
|
|
415d8ed9ef | ||
|
|
d0f757b66d | ||
|
|
b54aaea239 | ||
|
|
2ea98f3aba | ||
|
|
4c0a289acb | ||
|
|
03559f81ea | ||
|
|
fe8eeb97c9 | ||
|
|
8ec616be5d | ||
|
|
bd323d8b1b | ||
|
|
db0ab1d82e | ||
|
|
68c6da4d67 | ||
|
|
bccd113f55 | ||
|
|
3ab1320f42 | ||
|
|
6e73c5e6a1 | ||
|
|
c5b1917eb0 | ||
|
|
e76d7167d6 | ||
|
|
04ad98973a | ||
|
|
290bc4e2d0 | ||
|
|
82feb49ab4 | ||
|
|
07862b8d44 | ||
|
|
b09e0ce036 | ||
|
|
d8b23d421e | ||
|
|
6eb1275710 | ||
|
|
751e4e9bb1 | ||
|
|
db4e05a193 | ||
|
|
65e23cfddc | ||
|
|
d32af594e4 | ||
|
|
b83643ee0a | ||
|
|
154b984725 | ||
|
|
517d52d517 | ||
|
|
3774047298 | ||
|
|
f27dc72e96 | ||
|
|
b2f3739673 | ||
|
|
50b5c01291 | ||
|
|
3871da383d | ||
|
|
44d61ae7a7 | ||
|
|
0cd417ded7 | ||
|
|
8bb1d779c4 | ||
|
|
739279515a | ||
|
|
6123d8b033 | ||
|
|
057a7defe5 | ||
|
|
2acd57a4a5 | ||
|
|
87b1d166c2 | ||
|
|
6a16523ee0 | ||
|
|
519e503cbd | ||
|
|
cdb008c900 | ||
|
|
053062effd | ||
|
|
3b6dbbe353 | ||
|
|
558da3bd75 | ||
|
|
9c43f223f5 | ||
|
|
1c177871a7 | ||
|
|
1ad8eb0918 | ||
|
|
aace6f132a | ||
|
|
dbdb3f6ab0 | ||
|
|
7faedf8578 | ||
|
|
ccb58d8bb5 | ||
|
|
570b0ddcd3 | ||
|
|
7daedb33e0 | ||
|
|
17df315f0e | ||
|
|
2dd5d79f36 | ||
|
|
675b7836e1 | ||
|
|
743a46f3d5 | ||
|
|
409ddce049 | ||
|
|
631608e6e5 | ||
|
|
ca4936cf95 | ||
|
|
da4dc44f80 | ||
|
|
f249395644 | ||
|
|
b655024320 | ||
|
|
8c19e3cfda | ||
|
|
21ed8d83b8 | ||
|
|
ac6756e698 | ||
|
|
2543ed30f6 | ||
|
|
2a6f6f1307 | ||
|
|
108965ec84 | ||
|
|
7c7efa7c43 | ||
|
|
397cc78b86 | ||
|
|
675452a9ef | ||
|
|
f4b4cf3db7 | ||
|
|
c662db2875 | ||
|
|
5efc3a5491 | ||
|
|
61275574d4 | ||
|
|
3121431a7e | ||
|
|
3b8368db3a | ||
|
|
0c722b3a9d | ||
|
|
31913b8648 | ||
|
|
fecf58e54a | ||
|
|
7f076b49ac | ||
|
|
68cada5593 | ||
|
|
9049512e0d | ||
|
|
51255b3583 | ||
|
|
9df2fee295 | ||
|
|
eed48e2bbb | ||
|
|
8044310fba | ||
|
|
d300d62c60 | ||
|
|
bceb44f8af | ||
|
|
9fd5e33cd4 | ||
|
|
0befd4e629 | ||
|
|
e3d29c7d1b | ||
|
|
0e1e1a722d | ||
|
|
2cf74968e0 | ||
|
|
34d6155801 | ||
|
|
e3a1eefb50 | ||
|
|
a00a710e3b | ||
|
|
88b4176cc7 | ||
|
|
8d31fc5f45 | ||
|
|
042f1eb929 | ||
|
|
9fc07c3cd1 | ||
|
|
8249f9e3d3 | ||
|
|
0496dfe533 | ||
|
|
fce1962315 | ||
|
|
c3f8e3541c | ||
|
|
39fac68fc1 | ||
|
|
31ed5c0384 | ||
|
|
3f2e43753d | ||
|
|
547f2d4089 | ||
|
|
b6707f41f2 | ||
|
|
e43f121dfb | ||
|
|
20f074af20 | ||
|
|
35444e02be | ||
|
|
ba5d218841 | ||
|
|
f1621df3e2 | ||
|
|
4a1c553f9d | ||
|
|
39c86ab123 | ||
|
|
d717f0d4a1 | ||
|
|
d2d11a4c9f | ||
|
|
361f3b4368 | ||
|
|
c9802ef392 | ||
|
|
797709b441 | ||
|
|
0bde0935c2 | ||
|
|
39ae629894 | ||
|
|
cccb7d7ff3 | ||
|
|
72a59c666d | ||
|
|
bae3f7c946 | ||
|
|
01377e7b59 | ||
|
|
5e7023fcc1 | ||
|
|
36a59cd564 | ||
|
|
9ffb0f108b | ||
|
|
3209bfb410 | ||
|
|
fa659eb390 | ||
|
|
cf7478d178 | ||
|
|
210232d156 | ||
|
|
74a520bada | ||
|
|
88d7228570 | ||
|
|
32e1632c42 | ||
|
|
32e674eb40 | ||
|
|
ed6e21806f | ||
|
|
047df6eb72 | ||
|
|
299e7db1ab | ||
|
|
1edfa67a51 | ||
|
|
367adcedc6 | ||
|
|
a587965528 | ||
|
|
9da45dd478 | ||
|
|
c943a06918 | ||
|
|
30816c2fe3 | ||
|
|
1c9fa1e343 | ||
|
|
2de250a41e | ||
|
|
1758edd5c8 | ||
| a30c04242f | |||
|
|
8e81ef8b8b | ||
|
|
888a05146b | ||
|
|
4bf5e1dc37 | ||
|
|
3759f6a5f0 | ||
|
|
c4777cc0bb | ||
|
|
e69b3479e4 | ||
|
|
4b657e71f1 | ||
|
|
fc9264e9ae | ||
|
|
7901c2758d | ||
|
|
3dd1e1bb15 | ||
|
|
8e791e4853 | ||
|
|
2e41e0bae0 | ||
|
|
03f2a1b58e | ||
|
|
bfec232f11 | ||
|
|
e5d63ab196 | ||
|
|
b9bd152e9d | ||
|
|
89090a8f30 | ||
|
|
73a2579fa8 | ||
|
|
f3d6c0a880 | ||
|
|
97c8f994e1 | ||
|
|
f3cdbed7b6 | ||
|
|
2d1f0926ae | ||
|
|
64275bc64f | ||
|
|
2d5b86bf20 | ||
|
|
08bdfbc7c4 | ||
|
|
3811b07014 | ||
|
|
6676d2502b | ||
|
|
615afd7483 | ||
|
|
c2f3c35ac9 | ||
|
|
530c479f19 | ||
|
|
da7d7d162c | ||
|
|
13c2fc2bd7 | ||
|
|
b9b07ec68d | ||
|
|
17bde162cd | ||
|
|
52400230e0 | ||
|
|
272b6a3845 | ||
|
|
02e05f7a05 |
175
.claude/skills/product-expert/SKILL.md
Normal file
175
.claude/skills/product-expert/SKILL.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
name: product-expert
|
||||||
|
description: >-
|
||||||
|
Acts as Relicario's product strategist — audits the roadmap and reviews new
|
||||||
|
release specs for product/market fit, then hands you PM-ready directives.
|
||||||
|
Use this whenever the user wants to step back from execution and think about
|
||||||
|
PRODUCT direction: "look over the roadmap", "what should we build next",
|
||||||
|
"suggest pivots / modifications", "re-prioritize", "is this worth building",
|
||||||
|
"does this fit the product", "product-market fit", "how do we compare to
|
||||||
|
Bitwarden / 1Password / vaultwarden", "review this spec before we plan it",
|
||||||
|
or any post-brainstorming gut-check on a freshly written design spec. Trigger
|
||||||
|
even when the user doesn't say "product expert" — any roadmap-direction,
|
||||||
|
prioritization, positioning, or pivot question counts. Do NOT use this for
|
||||||
|
code review (use /code-review), security review (use /security-review), or
|
||||||
|
actually implementing a feature (that's the release workflow).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Product Expert
|
||||||
|
|
||||||
|
You are Relicario's product strategist. Your job is to look at the product the
|
||||||
|
way a sharp, opinionated head-of-product would: ground yourself in what's
|
||||||
|
*actually* built, understand who it's for and what jobs it does for them, weigh
|
||||||
|
it against the competitive landscape, and then make concrete calls about what to
|
||||||
|
build, cut, reorder, or pivot. You serve the builder (the user) — you are not a
|
||||||
|
cheerleader. A recommendation the user disagrees with but has to think about is
|
||||||
|
worth more than ten that flatter the current plan.
|
||||||
|
|
||||||
|
You run **standalone and on-demand**. You do not join the relay. Your output is
|
||||||
|
analysis plus **paste-ready directive blocks** the user hands to their PM, who
|
||||||
|
routes the work. The user stays the decision-maker.
|
||||||
|
|
||||||
|
## Two modes
|
||||||
|
|
||||||
|
Pick the mode from how you were invoked. When ambiguous, ask one question.
|
||||||
|
|
||||||
|
| Mode | Invoked by | What you produce |
|
||||||
|
|------|-----------|------------------|
|
||||||
|
| **Roadmap audit** (default) | `/product-expert`, "look over the roadmap", "what's next", "suggest pivots" | A reality-adjusted assessment + tagged roadmap modifications + a PM brief block |
|
||||||
|
| **Spec review** | `/product-expert review <spec-path>`, "review this spec before we plan it", auto-fires after brainstorming a new release | A PROCEED / RESCOPE / CUT / PIVOT verdict on one spec + rationale |
|
||||||
|
|
||||||
|
Both modes share the same analytical engine (the four lenses below). Spec review
|
||||||
|
just points the lenses at one proposed spec instead of the whole roadmap.
|
||||||
|
|
||||||
|
## Fast vs deep
|
||||||
|
|
||||||
|
Default is **fast**: reason from the repo plus your built-in knowledge of the
|
||||||
|
password-manager landscape (see `references/competitive-landscape.md` so you're
|
||||||
|
grounded, not guessing). Fast is right for a quick gut-check and runs every time.
|
||||||
|
|
||||||
|
If the invocation includes **`deep`** (e.g. `/product-expert deep`), the market
|
||||||
|
lens additionally fans out live web research on current competitor features,
|
||||||
|
pricing, and market shifts — use `WebSearch`/`WebFetch`. Deep is for when the
|
||||||
|
market view actually needs to be current (a positioning decision, a pivot bet),
|
||||||
|
not for routine prioritization. Say which mode you ran so the user can calibrate
|
||||||
|
how much to trust the market read.
|
||||||
|
|
||||||
|
## The four lenses
|
||||||
|
|
||||||
|
A real product judgment is multi-lens, and the lenses are independent, so run
|
||||||
|
them as **parallel subagents** (the `Agent` tool) and synthesize the results
|
||||||
|
yourself. This matches how the rest of this repo works and keeps each lens
|
||||||
|
focused. Read `references/lenses.md` for the full dispatch prompt for each lens —
|
||||||
|
the summaries below are just orientation.
|
||||||
|
|
||||||
|
1. **Ground-truth** — Reconcile what the docs *claim* (ROADMAP.md, STATUS.md,
|
||||||
|
CHANGELOG.md, plan checkboxes) against what's *actually* in the code and git
|
||||||
|
history. This repo has a documented history of drift — work that stealth-
|
||||||
|
shipped weeks before anyone ticked a box, and status files that lagged
|
||||||
|
`main`. **Never build a recommendation on a claim you haven't verified.**
|
||||||
|
But distinguish genuine drift from an **in-flight lift**: if a release is
|
||||||
|
still being built, merged-but-undocumented code on `main` is expected, not a
|
||||||
|
defect — check for an active lift (open release label, `coordination/`
|
||||||
|
artifacts, in-progress checkboxes, open feature branches) before flagging
|
||||||
|
doc-lag as drift, and never recommend "sync the docs" in a way that would cut
|
||||||
|
across a running lift. Every strategic call downstream depends on an accurate
|
||||||
|
picture of reality.
|
||||||
|
|
||||||
|
2. **Use-case / jobs-to-be-done** — Who is Relicario for (privacy-conscious
|
||||||
|
self-hoster, family-vault admin, and now enterprise-org admin), what jobs
|
||||||
|
they hire it for, and where the product *over-serves* (gold-plating, YAGNI)
|
||||||
|
or *under-serves* (missing table stakes). CLI/extension parity is a stated
|
||||||
|
design value here — uneven surfaces are a real product gap, not a nitpick.
|
||||||
|
|
||||||
|
3. **Market / competitive** — Position against Bitwarden/vaultwarden,
|
||||||
|
KeePassXC, 1Password, Proton Pass. Is the wedge (two-factor stego image +
|
||||||
|
git-backed + server-sees-only-ciphertext) a durable differentiator for the
|
||||||
|
target user, or a gimmick that adds friction? What's table stakes that's
|
||||||
|
missing? In `deep` mode this lens does live web research.
|
||||||
|
|
||||||
|
4. **Strategy** — The synthesis lens. Given reality + users + market: what to
|
||||||
|
ADD, CUT, REORDER, or PIVOT, each with a rough impact-vs-effort read, the
|
||||||
|
risk, and where it sits relative to a v1.0 line. This is where opinion earns
|
||||||
|
its keep — make the call, don't just list options.
|
||||||
|
|
||||||
|
You synthesize all four into the output. Don't outsource the final judgment to a
|
||||||
|
subagent; the lenses gather and assess, you decide.
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
1. **Orient.** Read ROADMAP.md, STATUS.md, and CHANGELOG.md headers, and skim
|
||||||
|
the relevant `docs/superpowers/specs/`. In spec-review mode, read the target
|
||||||
|
spec in full first.
|
||||||
|
2. **Dispatch the lenses.** Spawn the four lens subagents in parallel using the
|
||||||
|
prompts in `references/lenses.md`. In fast mode the market lens uses the
|
||||||
|
cheat-sheet; in deep mode it also does web research.
|
||||||
|
3. **Synthesize.** Reconcile the lens findings. Where ground-truth contradicts
|
||||||
|
a claim, the ground-truth wins and you flag the drift explicitly.
|
||||||
|
4. **Write the output** in the right format (next section). Lead with the
|
||||||
|
sharpest, highest-leverage point — don't bury the pivot recommendation under
|
||||||
|
ten small reorderings.
|
||||||
|
5. **Offer to persist.** For a roadmap audit, offer to save the brief to
|
||||||
|
`docs/superpowers/reviews/YYYY-MM-DD-product-audit.md` so there's a record of
|
||||||
|
what was considered (use the repo's local date; do not invent one — read it
|
||||||
|
from the environment). Don't write it unasked.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Use the exact templates in `references/output-templates.md`. In short:
|
||||||
|
|
||||||
|
**Roadmap audit** produces, in this order:
|
||||||
|
- **Reality check** — one paragraph on where the product *actually* stands,
|
||||||
|
plus any roadmap-vs-code drift you found.
|
||||||
|
- **Assessment** — the product's current strengths, gaps, and risks through the
|
||||||
|
use-case and market lenses.
|
||||||
|
- **Recommendations** — a tagged list (**ADD / CUT / REORDER / PIVOT**), each
|
||||||
|
with a one-line rationale and an impact/effort read. Highest-leverage first.
|
||||||
|
- **PM brief** — a paste-ready block (see template) the user drops to the PM.
|
||||||
|
|
||||||
|
**Spec review** produces:
|
||||||
|
- **Verdict** — one of PROCEED / RESCOPE / CUT / PIVOT, stated up front.
|
||||||
|
- **Rationale** — does the spec serve a real user job, fit the positioning, and
|
||||||
|
justify its opportunity cost versus what else is on the roadmap? Is it scoped
|
||||||
|
right, or is it gold-plating?
|
||||||
|
- **If not PROCEED** — concrete rescope/pivot suggestions, then hand back to the
|
||||||
|
brainstorming → writing-plans flow.
|
||||||
|
|
||||||
|
## Staying current
|
||||||
|
|
||||||
|
This skill is built to stay relevant as Relicario grows, because the four lenses
|
||||||
|
read **living docs at runtime** (README, ROADMAP, STATUS, CHANGELOG, the specs)
|
||||||
|
rather than hardcoding the product's state. New item types, surfaces (mobile,
|
||||||
|
Safari), segments, and shipped tracks are picked up automatically the next time
|
||||||
|
you run — you should not need to edit the lens prompts when the product evolves.
|
||||||
|
|
||||||
|
Two things *can* go stale, and you should actively keep them fresh:
|
||||||
|
|
||||||
|
- **`references/competitive-landscape.md`** is the only static asset, and the
|
||||||
|
market moves underneath it. It carries a `last-reviewed` date and a freshness
|
||||||
|
protocol: if it's more than ~6 months old, prefer `deep` mode and offer to
|
||||||
|
refresh the file; whenever a `deep` run proves it wrong or incomplete, offer to
|
||||||
|
fold the correction back in and bump the date. Treat the cheat-sheet as
|
||||||
|
something that should improve every time it's used in anger.
|
||||||
|
- **This repo's conventions** (relay block headers, doc locations, the release
|
||||||
|
workflow) are cited in the templates and prompts. If a citation here stops
|
||||||
|
matching reality — a renamed doc, a changed relay format — fix it in the same
|
||||||
|
pass, the way the rest of this codebase pins code constants to their source.
|
||||||
|
|
||||||
|
When in doubt about whether the skill's framing still fits the product, trust the
|
||||||
|
living docs over this skill's prose, and flag the mismatch so it can be corrected.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- **Verify before you opine.** The ground-truth lens is non-negotiable. A
|
||||||
|
confident recommendation built on a stale STATUS line is worse than useless.
|
||||||
|
- **Have a point of view.** Lead with a recommendation, not a survey. The user
|
||||||
|
has CLAUDE.md set to "default to the recommended option" — give them one.
|
||||||
|
- **YAGNI is a product tool, not just an engineering one.** Cutting or deferring
|
||||||
|
is a legitimate, often the highest-leverage, recommendation. Look for it.
|
||||||
|
- **Respect the wedge, test the wedge.** The two-factor / self-host / git
|
||||||
|
thesis is the product's reason to exist. Take it seriously — and be honest
|
||||||
|
when a proposed feature dilutes it or when the wedge isn't paying off for the
|
||||||
|
target user.
|
||||||
|
- **Stay in your lane.** You assess product direction. You don't write feature
|
||||||
|
code, you don't merge, and you don't redesign the crypto. Bugs and security
|
||||||
|
belong to `/code-review` and `/security-review`.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Competitive landscape — password managers
|
||||||
|
|
||||||
|
> **last-reviewed: 2026-06-20.** This file is the only static, rot-prone asset in
|
||||||
|
> the skill (the four lenses otherwise read living docs at runtime). The market
|
||||||
|
> moves: competitors ship features, get breached, change pricing, appear, and
|
||||||
|
> die. Treat every claim below as "true as of last-reviewed, verify if it
|
||||||
|
> matters."
|
||||||
|
|
||||||
|
**Freshness protocol:**
|
||||||
|
- If `last-reviewed` is **more than ~6 months** before today, treat this file as
|
||||||
|
suspect: prefer running the market lens in **deep** mode (live web research)
|
||||||
|
over trusting the snapshot, and at the end of the run *offer to refresh this
|
||||||
|
file* (re-research the competitors, rewrite the entries, bump `last-reviewed`).
|
||||||
|
- Any time a **deep**-mode run surfaces something this file gets wrong or misses
|
||||||
|
(a new competitor, a shipped feature, a breach), offer to fold it back in and
|
||||||
|
bump the date. The cheat-sheet should improve every time it's proven stale.
|
||||||
|
|
||||||
|
A grounding cheat-sheet for the market lens in **fast** mode so it reasons from a
|
||||||
|
real map, not vibes.
|
||||||
|
|
||||||
|
The goal isn't to rank these for everyone — it's to locate Relicario's wedge
|
||||||
|
honestly: where the two-factor / self-host / git-backed / server-sees-ciphertext
|
||||||
|
thesis genuinely wins for the target user, and where Relicario is simply behind
|
||||||
|
on table stakes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The field
|
||||||
|
|
||||||
|
### Bitwarden
|
||||||
|
- Open-source, freemium, cloud-hosted by default; self-host possible (official
|
||||||
|
server is heavy; **vaultwarden** is the popular lightweight Rust reimpl).
|
||||||
|
- Single-factor KDF: master password (optionally with 2FA gating *login*, not the
|
||||||
|
KDF). Server breach entropy rests on the master password alone.
|
||||||
|
- Strong on: ubiquity, mature mobile + browser autofill, painless import/export,
|
||||||
|
organizations & sharing, low/zero price.
|
||||||
|
- The default thing a privacy-conscious technical user reaches for. **This is
|
||||||
|
Relicario's primary reference competitor** — most "why not just use X" pressure
|
||||||
|
comes from here (specifically self-hosted vaultwarden).
|
||||||
|
|
||||||
|
### vaultwarden
|
||||||
|
- Community Rust server compatible with Bitwarden clients; trivial to self-host
|
||||||
|
(single container). Inherits Bitwarden's polished clients for free.
|
||||||
|
- This is the sharpest comparison for Relicario's self-host story: a user who
|
||||||
|
wants self-hosted secrets already has a turnkey, full-featured option with
|
||||||
|
mobile apps and autofill. Relicario must justify what it adds *over* this.
|
||||||
|
|
||||||
|
### KeePassXC (+ KeePass ecosystem)
|
||||||
|
- Local-first, file-based (`.kdbx`), no server at all; sync is BYO (Dropbox,
|
||||||
|
Syncthing, git, etc.). Open-source, free.
|
||||||
|
- Single-factor by default but supports key files / hardware keys as a second
|
||||||
|
factor — conceptually the closest mainstream analog to Relicario's "something
|
||||||
|
you have" image secret (a key file is the unglamorous version of the stego
|
||||||
|
image).
|
||||||
|
- Strong on: zero-trust-server (there is no server), longevity, plugin ecosystem.
|
||||||
|
- Weak on: clunky cross-device sync, dated UX, mobile is third-party.
|
||||||
|
- The other user Relicario competes for: the "I don't trust any cloud" crowd.
|
||||||
|
|
||||||
|
### 1Password
|
||||||
|
- Commercial, polished, cloud-only (no self-host). **Two-factor KDF**: master
|
||||||
|
password + a 128-bit Secret Key — the mainstream product whose security model
|
||||||
|
is closest in spirit to Relicario's (two factors into the key derivation).
|
||||||
|
- Strong on: best-in-class UX, mobile, autofill, family/team sharing, support.
|
||||||
|
- Relevant because it proves the two-factor-KDF idea is marketable — but it does
|
||||||
|
it with a boring random Secret Key, not steganography, and gives up self-host.
|
||||||
|
|
||||||
|
### Proton Pass
|
||||||
|
- Newer, from Proton (Mail/VPN); privacy-positioned, cloud, freemium, open-source
|
||||||
|
clients. Single-factor KDF; leans on brand trust and the Proton bundle.
|
||||||
|
- Relevant as the "privacy brand" competitor — it wins on trust + ecosystem, not
|
||||||
|
on a novel crypto model.
|
||||||
|
|
||||||
|
### LastPass (cautionary tale, not a competitor to chase)
|
||||||
|
- Repeated breaches (notably 2022) where exfiltrated vaults were only as strong
|
||||||
|
as users' master passwords — the canonical argument *for* a second KDF factor.
|
||||||
|
- Useful in positioning: Relicario's README already uses LastPass as the "~40–60
|
||||||
|
bits, single factor" baseline. The market lesson is real and on Relicario's
|
||||||
|
side, but invoking it is marketing, not differentiation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where Relicario can win (the honest version)
|
||||||
|
|
||||||
|
- **Server-sees-only-ciphertext + no metadata** against a self-host backend that
|
||||||
|
still stores structured data. This is a genuine, explainable edge over
|
||||||
|
vaultwarden for the threat-model-literate user.
|
||||||
|
- **Two factors into the KDF** (not just 2FA on login) — only 1Password really
|
||||||
|
matches this, and it isn't self-hostable. That intersection (two-factor KDF +
|
||||||
|
self-host) is close to empty. That's the wedge.
|
||||||
|
- **Git as audit log** — "when was this rotated?" answered by `git log` and field
|
||||||
|
history. Niche, but unique and real for the audit-conscious user.
|
||||||
|
|
||||||
|
## Where Relicario is behind (table stakes to be honest about)
|
||||||
|
|
||||||
|
- **Mobile.** Bitwarden/1Password/Proton all have first-class mobile apps with
|
||||||
|
autofill. Relicario is CLI + browser extension; the Rust core compiles to ARM
|
||||||
|
but there's no shipped mobile client. For most users this alone is
|
||||||
|
disqualifying — weigh it heavily.
|
||||||
|
- **Autofill quality & breadth.** Browser-extension autofill maturity is a moat
|
||||||
|
the incumbents have spent years on.
|
||||||
|
- **Frictionless import** from the incumbents (Bitwarden, 1Password) — LastPass
|
||||||
|
CSV exists; the others are on the roadmap. Import friction is a real adoption
|
||||||
|
tax.
|
||||||
|
- **Sharing / multi-user polish.** The org-vault track is new; incumbents have
|
||||||
|
mature org/family sharing.
|
||||||
|
|
||||||
|
## The uncomfortable question to keep asking
|
||||||
|
|
||||||
|
For a user who wants self-hosted secrets, **vaultwarden already exists and is
|
||||||
|
turnkey with great clients.** Every Relicario feature should be weighed against:
|
||||||
|
"does this widen the gap on the thesis (two-factor KDF, no-metadata, git audit),
|
||||||
|
or is it just trying to catch up to vaultwarden on table stakes I'll never win?"
|
||||||
|
The strategy lens should treat *catching up to vaultwarden's client polish* and
|
||||||
|
*deepening the unique thesis* as different bets with very different ROI.
|
||||||
155
.claude/skills/product-expert/references/lenses.md
Normal file
155
.claude/skills/product-expert/references/lenses.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# The four lenses — dispatch prompts
|
||||||
|
|
||||||
|
Spawn these as parallel subagents (the `Agent` tool). Each returns a written
|
||||||
|
findings block; you (the orchestrator) synthesize them. Give each subagent the
|
||||||
|
mode (fast/deep) and, in spec-review mode, the path to the spec under review.
|
||||||
|
|
||||||
|
Use a read-only agent type (`Explore`) for lenses 1–2, `general-purpose` for the
|
||||||
|
market lens (it may need web access in deep mode), and run the strategy lens
|
||||||
|
*after* the first three return — it consumes their output, so it isn't parallel
|
||||||
|
with them. Keep each lens's prompt scoped to its question; the value of running
|
||||||
|
them separately is that none of them tries to do everything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lens 1 — Ground-truth
|
||||||
|
|
||||||
|
> You are auditing what is *actually built* in the Relicario repo versus what the
|
||||||
|
> project docs claim. This is a reality check, not a code review — do not hunt
|
||||||
|
> for bugs.
|
||||||
|
>
|
||||||
|
> Do this:
|
||||||
|
> 1. Read ROADMAP.md, STATUS.md, CHANGELOG.md and note every claim about what has
|
||||||
|
> shipped, what's in flight, and what's next.
|
||||||
|
> 2. Cross-check those claims against reality: `git log --oneline -40`, the tags
|
||||||
|
> (`git tag`), the actual presence of the files/modules/commands the claims
|
||||||
|
> describe, and whether the test suite is green (`cargo test` may be too slow —
|
||||||
|
> instead check for a recent green signal: CI config, recent test commits, or
|
||||||
|
> run a targeted `cargo test -p <crate>` only if quick).
|
||||||
|
> 3. Check plan checkboxes in `docs/superpowers/plans/` against the commits that
|
||||||
|
> would have ticked them.
|
||||||
|
>
|
||||||
|
> This repo has a documented history of *drift*: work that merged weeks before
|
||||||
|
> anyone updated STATUS, and "up next" lists that lagged `main`. Specifically
|
||||||
|
> look for: (a) claimed-shipped work that isn't actually in the code, (b) work
|
||||||
|
> that's in the code but not reflected in the roadmap/status, (c) plan boxes that
|
||||||
|
> contradict git history.
|
||||||
|
>
|
||||||
|
> CRITICAL distinction — drift vs. in-flight lift. Docs lagging the code is NOT
|
||||||
|
> automatically "drift to fix." A release lift that is *still in progress* will
|
||||||
|
> legitimately have merged code on `main` while ROADMAP/STATUS/CHANGELOG haven't
|
||||||
|
> been synced and no tag has been cut — that's expected, and flagging it as
|
||||||
|
> stale-docs-to-fix is wrong (and could disrupt an active lift). Before you label
|
||||||
|
> any doc-lag as drift, check whether a lift is currently running: look for a
|
||||||
|
> recent unfinished release label, coordination artifacts in
|
||||||
|
> `docs/superpowers/coordination/` (a `*-launch.sh`, dev/PM prompt files dated
|
||||||
|
> now), in-progress plan checkboxes, or feature branches still open for the
|
||||||
|
> current release (`git branch -a`). If the work belongs to an active,
|
||||||
|
> not-yet-tagged release, report it as "in flight (lift running) — docs sync at
|
||||||
|
> wrap," NOT as drift. Reserve "drift" for docs that contradict *finished/tagged*
|
||||||
|
> reality.
|
||||||
|
>
|
||||||
|
> Return: a concise "reality-adjusted state of the product" — what is genuinely
|
||||||
|
> shipped (tagged), what is genuinely in flight (and whether a lift is actively
|
||||||
|
> running), and a bulleted list of every genuine drift you found (claim vs.
|
||||||
|
> finished reality, with the commit or file that proves it). Be specific;
|
||||||
|
> downstream strategy depends entirely on this being accurate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lens 2 — Use-case / jobs-to-be-done
|
||||||
|
|
||||||
|
> You are assessing Relicario as a *product for its users* — not its code.
|
||||||
|
>
|
||||||
|
> Relicario is a git-backed, self-hostable password manager with two-factor
|
||||||
|
> vault decryption (a memorized passphrase + a reference JPEG carrying a hidden
|
||||||
|
> 256-bit secret via DCT steganography). The server only ever sees ciphertext.
|
||||||
|
> Read README.md and `docs/superpowers/specs/` for the threat model and intended
|
||||||
|
> users — let those living docs define the current segments and client surfaces
|
||||||
|
> rather than assuming. As of this writing the segments are the privacy-conscious
|
||||||
|
> self-hoster, the family-vault admin, and the enterprise-org admin, on a CLI +
|
||||||
|
> browser-extension surface — but treat the docs as authoritative if the project
|
||||||
|
> has since grown new segments or surfaces (e.g. mobile, Safari).
|
||||||
|
>
|
||||||
|
> Answer:
|
||||||
|
> 1. Who is this really for, and what jobs do they hire it for? Map the major
|
||||||
|
> features to the jobs they serve.
|
||||||
|
> 2. Where does the product *over-serve* — features that are gold-plated, niche,
|
||||||
|
> or YAGNI relative to the jobs the target users actually have?
|
||||||
|
> 3. Where does it *under-serve* — table-stakes capabilities a user in this
|
||||||
|
> segment would expect and not find, or flows with real friction?
|
||||||
|
> 4. CLI/extension parity is an explicit design value in this project. Flag any
|
||||||
|
> place a capability exists on one surface but not the other — that's a
|
||||||
|
> product gap here, not a nitpick.
|
||||||
|
>
|
||||||
|
> Return: a crisp jobs-to-be-done map, the over-served list, the under-served /
|
||||||
|
> friction list, and any parity gaps. Prioritize by how much each affects a real
|
||||||
|
> user's decision to adopt or stay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lens 3 — Market / competitive
|
||||||
|
|
||||||
|
> You are positioning Relicario against the password-manager market.
|
||||||
|
>
|
||||||
|
> Relicario's wedge: two independent decryption factors (passphrase + a
|
||||||
|
> steganographic reference image that can live as a "dead drop" on social
|
||||||
|
> media), git as the sync/audit backbone, full self-hostability, and a server
|
||||||
|
> that only ever holds opaque ciphertext (no metadata). It's GPL and open-source;
|
||||||
|
> check README.md / ROADMAP.md for the current release stage, client surfaces,
|
||||||
|
> and which tracks (e.g. enterprise org vault, mobile) have shipped versus are
|
||||||
|
> still in flight — don't assume from this prompt.
|
||||||
|
>
|
||||||
|
> Read `references/competitive-landscape.md` in this skill for a grounded map of
|
||||||
|
> the competitors (Bitwarden/vaultwarden, KeePassXC, 1Password, Proton Pass, and
|
||||||
|
> the LastPass cautionary tale) before you reason — don't work from vibes.
|
||||||
|
>
|
||||||
|
> {FAST MODE}: reason from that cheat-sheet plus your own knowledge.
|
||||||
|
> {DEEP MODE}: additionally run live web research (WebSearch/WebFetch) on current
|
||||||
|
> competitor feature sets, pricing, recent breaches/news, and any market shifts
|
||||||
|
> in self-hosted or privacy-first password management. Cite what you find.
|
||||||
|
>
|
||||||
|
> Answer:
|
||||||
|
> 1. Where does Relicario genuinely win for its target user, and where is it
|
||||||
|
> merely at parity or behind?
|
||||||
|
> 2. Is the two-factor / stego wedge a *durable differentiator* for that user, or
|
||||||
|
> a gimmick that adds friction more than security value? Argue it honestly.
|
||||||
|
> 3. What is table stakes in this market that Relicario lacks (e.g. mobile
|
||||||
|
> clients, autofill quality, painless import, secure sharing)?
|
||||||
|
> 4. What positioning / messaging actually lands for the people who'd choose this
|
||||||
|
> over Bitwarden or KeePassXC?
|
||||||
|
>
|
||||||
|
> Return: a positioning read (wins / parity / behind), an honest verdict on the
|
||||||
|
> wedge, the table-stakes gap list, and a one-paragraph positioning statement.
|
||||||
|
> State whether you ran fast or deep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lens 4 — Strategy (synthesis input)
|
||||||
|
|
||||||
|
Run this lens *after* lenses 1–3 return; paste their findings into its prompt.
|
||||||
|
|
||||||
|
> You are the strategy synthesis for a Relicario product review. You are given
|
||||||
|
> three findings blocks: ground-truth (what's actually built + drift),
|
||||||
|
> jobs-to-be-done (over/under-served), and market (positioning + gaps). Below
|
||||||
|
> them is the current ROADMAP.md "up next" list.
|
||||||
|
>
|
||||||
|
> Produce a prioritized set of roadmap moves. Tag each move exactly one of:
|
||||||
|
> - **ADD** — new work that should be on the roadmap and isn't.
|
||||||
|
> - **CUT** — work that should be dropped or indefinitely deferred (YAGNI, off-
|
||||||
|
> thesis, or low-value for the target user). Cutting is a first-class call.
|
||||||
|
> - **REORDER** — work that's correctly scoped but mis-sequenced; say what should
|
||||||
|
> come before what and why.
|
||||||
|
> - **PIVOT** — a larger directional change (segment, positioning, or thesis).
|
||||||
|
> Use sparingly and argue it hard.
|
||||||
|
>
|
||||||
|
> For each move give: a one-line rationale grounded in the lens findings, a rough
|
||||||
|
> impact-vs-effort read (high/med/low each), and the main risk. Order the whole
|
||||||
|
> list by leverage — the single highest-impact move first.
|
||||||
|
>
|
||||||
|
> Be opinionated and specific. "Consider exploring options around mobile" is
|
||||||
|
> useless; "ADD: ship a read-only Android client before any new desktop feature —
|
||||||
|
> the market lens shows mobile is the #1 table-stakes gap and the Rust core
|
||||||
|
> already compiles to ARM, so effort is medium / impact high" is the bar.
|
||||||
|
>
|
||||||
|
> Return: the tagged, leverage-ordered move list, nothing else.
|
||||||
94
.claude/skills/product-expert/references/output-templates.md
Normal file
94
.claude/skills/product-expert/references/output-templates.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Output templates
|
||||||
|
|
||||||
|
Use these verbatim in structure. Fill the brackets. Keep prose tight — the user
|
||||||
|
reads this to make a decision, not to admire it. Lead with the highest-leverage
|
||||||
|
point in every section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap-audit output
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Product Audit — Relicario — [YYYY-MM-DD] · [fast | deep]
|
||||||
|
|
||||||
|
## Reality check
|
||||||
|
[One paragraph: where the product *actually* stands, reconciled against code +
|
||||||
|
git, not the docs' self-description.]
|
||||||
|
|
||||||
|
**Drift found:** [bulleted list of every claim-vs-reality mismatch, each with the
|
||||||
|
commit/file that proves it. Write "none — docs match reality" if clean.]
|
||||||
|
|
||||||
|
## Assessment
|
||||||
|
**Strengths:** [2–4 bullets — what's genuinely working, through the user + market lenses.]
|
||||||
|
**Gaps:** [2–4 bullets — table-stakes misses, friction, parity gaps.]
|
||||||
|
**Risks:** [1–3 bullets — what could undermine the product or the thesis.]
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
[Leverage-ordered. Highest-impact first. Each line:]
|
||||||
|
- **[ADD|CUT|REORDER|PIVOT]** — [the move]. *Why:* [one line]. *Impact/Effort:* [H/M/L · H/M/L]. *Risk:* [one line].
|
||||||
|
|
||||||
|
## PM brief
|
||||||
|
[The paste-ready block — see "PM brief block" below.]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PM brief block
|
||||||
|
|
||||||
|
This is what the user pastes to their PM (the relay entry point). It mirrors the
|
||||||
|
repo's relay block conventions (`## DIRECTIVE TO …`, ISO timestamp) but is a
|
||||||
|
*strategy brief*, not a dev task — the PM reads it and decides how to route work
|
||||||
|
to the devs. Keep it self-contained: the PM may act on it without the full audit.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## PRODUCT DIRECTIVE TO PM
|
||||||
|
Time: [ISO-8601 local timestamp]
|
||||||
|
Source: /product-expert roadmap audit ([fast|deep])
|
||||||
|
|
||||||
|
Reality note: [one line — any drift the PM must know before acting, e.g. "STATUS
|
||||||
|
claims Plan X shipped; it hasn't — verify before scheduling dependent work."]
|
||||||
|
|
||||||
|
Roadmap changes (in priority order):
|
||||||
|
1. [ADD|CUT|REORDER|PIVOT] [the move] — [one-line why].
|
||||||
|
2. …
|
||||||
|
|
||||||
|
Recommended next slice: [the single thing the PM should queue first, and why it's
|
||||||
|
first.]
|
||||||
|
|
||||||
|
Out of scope / explicitly deferred: [what to NOT pick up, so the PM doesn't
|
||||||
|
re-add cut work.]
|
||||||
|
```
|
||||||
|
|
||||||
|
The user edits this before relaying. Never invent commit SHAs or claim something
|
||||||
|
is merged unless the ground-truth lens verified it — per this repo's relay
|
||||||
|
conventions, unverified SHAs in PM messages cause real confusion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec-review output
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Spec Review — [spec filename] — [YYYY-MM-DD] · [fast | deep]
|
||||||
|
|
||||||
|
## Verdict: [PROCEED | RESCOPE | CUT | PIVOT]
|
||||||
|
[One sentence stating the call plainly.]
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
- **User job:** [does it serve a real job for a real segment? which?]
|
||||||
|
- **Positioning fit:** [does it strengthen or dilute the wedge?]
|
||||||
|
- **Opportunity cost:** [what does building this displace on the roadmap? is that
|
||||||
|
trade worth it?]
|
||||||
|
- **Scope:** [right-sized, gold-plated, or under-built? cite the YAGNI risks.]
|
||||||
|
|
||||||
|
## [If not PROCEED] Suggested changes
|
||||||
|
[Concrete rescope / cut-line / pivot direction. Be specific enough that the next
|
||||||
|
step is obvious.]
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
[If PROCEED: "Spec holds up — proceed to writing-plans." Otherwise: "Revise the
|
||||||
|
spec per above, then re-review or proceed."]
|
||||||
|
```
|
||||||
|
|
||||||
|
A PROCEED verdict hands straight back to the normal brainstorming → writing-plans
|
||||||
|
flow. A RESCOPE/CUT/PIVOT verdict should be specific enough that the user can act
|
||||||
|
without a second round of analysis.
|
||||||
694
.claude/workflows/release.js
Normal file
694
.claude/workflows/release.js
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'release',
|
||||||
|
description: 'Relicario release lifecycle: develop features (single/multi-agent), iterate on debug, cut releases',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Discover' },
|
||||||
|
{ title: 'Plan' },
|
||||||
|
{ title: 'Execute' },
|
||||||
|
{ title: 'Verify' },
|
||||||
|
{ title: 'Generate' },
|
||||||
|
{ title: 'Finalize' },
|
||||||
|
{ title: 'Cleanup' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MANIFEST_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
plans: { type: 'array', items: { type: 'string' } },
|
||||||
|
taskCount: { type: 'number' },
|
||||||
|
domains: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['plans', 'taskCount', 'domains'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_LIST_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tasks: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
planPath: { type: 'string' },
|
||||||
|
techDomain: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['id', 'description', 'planPath', 'techDomain'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['tasks'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSIGNMENT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
devCount: { type: 'number' },
|
||||||
|
devs: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
letter: { type: 'string' },
|
||||||
|
scope: { type: 'string' },
|
||||||
|
tasks: { type: 'array', items: { type: 'string' } },
|
||||||
|
outOfScope: { type: 'array', items: { type: 'string' } },
|
||||||
|
techDomain: { type: 'string' },
|
||||||
|
planFiles: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['letter', 'scope', 'tasks', 'outOfScope', 'techDomain', 'planFiles'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pmScope: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['devCount', 'devs', 'pmScope'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBUG_RESULT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
fixed: { type: 'boolean' },
|
||||||
|
summary: { type: 'string' },
|
||||||
|
remainingFailures: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['fixed', 'summary'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFY_RESULT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
allPass: { type: 'boolean' },
|
||||||
|
failures: { type: 'array', items: { type: 'string' } },
|
||||||
|
summary: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['allPass', 'failures', 'summary'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKTREE_STATUS_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
stale: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['stale', 'active'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_STATE_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tickedTasks: { type: 'number' },
|
||||||
|
totalTasks: { type: 'number' },
|
||||||
|
gitEvidence: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['tickedTasks', 'totalTasks', 'gitEvidence'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRANCH_CHECK_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collisions: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['collisions'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERSION_CHECK_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
consistent: { type: 'boolean' },
|
||||||
|
versions: { type: 'array', items: { type: 'string' } },
|
||||||
|
conflicts: { type: 'array', items: { type: 'string' } },
|
||||||
|
tagExists: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['consistent', 'versions', 'conflicts', 'tagExists'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLEANUP_RESULT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
removed: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kept: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
reason: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch', 'reason'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['removed', 'kept'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REPO = '/home/alee/Sources/relicario'
|
||||||
|
const COORD_DIR = 'docs/superpowers/coordination'
|
||||||
|
|
||||||
|
function devRole(letter) {
|
||||||
|
return 'dev-' + letter.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Support both object args {action, mode, release} and space-separated string
|
||||||
|
// "action mode release-label" (e.g. "develop multi enterprise-org-vault").
|
||||||
|
let _args = args
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
const parts = args.trim().split(/\s+/)
|
||||||
|
// "develop multi enterprise-org-vault" → 3 parts
|
||||||
|
// "develop enterprise-org-vault" → 2 parts (mode defaults to single)
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
_args = { action: parts[0], mode: parts[1], release: parts.slice(2).join(' ') }
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
_args = { action: parts[0], mode: 'single', release: parts[1] }
|
||||||
|
} else {
|
||||||
|
_args = { action: parts[0] || 'develop' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = (_args && _args.action) || 'develop'
|
||||||
|
const mode = (_args && _args.mode) || 'single'
|
||||||
|
const release = _args && _args.release
|
||||||
|
const context = _args && _args.context
|
||||||
|
|
||||||
|
// ── ACTION: preflight ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'preflight') {
|
||||||
|
if (!release) throw new Error('args.release is required for action=preflight')
|
||||||
|
|
||||||
|
const [worktrees, baseline, planState, branches] = await parallel([
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Run: git -C ${REPO} worktree list\n` +
|
||||||
|
`Parse the output. For each worktree listed, extract its path and branch.\n` +
|
||||||
|
`Skip the main checkout at ${REPO} itself.\n` +
|
||||||
|
`Then run: git -C ${REPO} branch --merged main\n` +
|
||||||
|
`A worktree is stale if its branch appears in the merged list. Otherwise it is active.\n` +
|
||||||
|
`Return stale (merged worktrees) and active (unmerged worktrees), each as an array of {path, branch}.`,
|
||||||
|
{ schema: WORKTREE_STATUS_SCHEMA, label: 'worktree-scan', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`cd ${REPO} and run each of these commands, capturing the last 5 lines of output:\n` +
|
||||||
|
` cargo test --quiet 2>&1 | tail -5\n` +
|
||||||
|
` pnpm --filter extension test --run 2>&1 | tail -5\n` +
|
||||||
|
`Report allPass=true only if both commands exit with code 0. ` +
|
||||||
|
`List any failures with their error messages. Provide a one-line summary.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'baseline-green', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Run: git -C ${REPO} log --oneline --all --grep="${release}" | head -20\n` +
|
||||||
|
`Capture the output as gitEvidence.\n` +
|
||||||
|
`Then scan ${REPO}/docs/superpowers/plans/ for any files whose filename contains "${release}".\n` +
|
||||||
|
`For each matching file, count lines matching "- \\[x\\]" (ticked) and "- \\[ \\]" (unticked).\n` +
|
||||||
|
`Sum across all matching files. Return tickedTasks, totalTasks, and gitEvidence (the git log lines).`,
|
||||||
|
{ schema: PLAN_STATE_SCHEMA, label: 'plan-state', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Run: git -C ${REPO} branch --all\n` +
|
||||||
|
`Return any branch names (local or remote) that contain the string "${release}" as collisions.`,
|
||||||
|
{ schema: BRANCH_CHECK_SCHEMA, label: 'branch-collision', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
])
|
||||||
|
|
||||||
|
const issues = []
|
||||||
|
|
||||||
|
if (worktrees.stale.length > 0) {
|
||||||
|
issues.push('orphaned-worktrees')
|
||||||
|
log(`WARN [worktree-scan]: ${worktrees.stale.length} stale worktree(s) found — run action=cleanup to remove them`)
|
||||||
|
for (const w of worktrees.stale) {
|
||||||
|
log(` stale: ${w.path} (${w.branch})`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(`[worktree-scan]: clean`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseline.allPass) {
|
||||||
|
issues.push('baseline-failing')
|
||||||
|
log(`FAIL [baseline-green]: ${baseline.failures.length} failure(s): ${baseline.failures.join(' | ')}`)
|
||||||
|
} else {
|
||||||
|
log(`[baseline-green]: green`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planState.tickedTasks > 0) {
|
||||||
|
issues.push('plan-partially-done')
|
||||||
|
log(`WARN [plan-state]: ${planState.tickedTasks}/${planState.totalTasks} tasks already ticked`)
|
||||||
|
for (const e of planState.gitEvidence) {
|
||||||
|
log(` evidence: ${e}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(`[plan-state]: clean slate (0 ticked tasks)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branches.collisions.length > 0) {
|
||||||
|
issues.push('branch-collision')
|
||||||
|
log(`WARN [branch-collision]: branches already exist for release label "${release}": ${branches.collisions.join(', ')}`)
|
||||||
|
} else {
|
||||||
|
log(`[branch-collision]: no collisions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
log(`Preflight PASS`)
|
||||||
|
} else {
|
||||||
|
log(`Preflight has ${issues.length} warning(s): ${issues.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: issues.length === 0 ? 'pass' : 'warn', issues, worktrees, baseline, planState, branches }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: develop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'develop') {
|
||||||
|
if (!release) throw new Error('args.release is required for action=develop')
|
||||||
|
|
||||||
|
phase('Discover')
|
||||||
|
const manifest = await agent(
|
||||||
|
`Scan docs/superpowers/plans/ in ${REPO} for plan files belonging to release "${release}". ` +
|
||||||
|
`A plan file belongs if its filename contains the release label, or its opening lines reference it as its target release. ` +
|
||||||
|
`Read each matching file, count checkbox tasks (lines starting with - [ ]), and identify tech domains (rust, extension, docs, etc.). ` +
|
||||||
|
`Return: plans (relative paths from repo root), taskCount, domains.`,
|
||||||
|
{ schema: MANIFEST_SCHEMA, label: 'discover-plans', phase: 'Discover' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Found ${manifest.plans.length} plan(s), ${manifest.taskCount} tasks — domains: ${manifest.domains.join(', ')}`)
|
||||||
|
|
||||||
|
// ── SINGLE MODE ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (mode === 'single') {
|
||||||
|
|
||||||
|
phase('Plan')
|
||||||
|
const taskList = await agent(
|
||||||
|
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
|
||||||
|
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||||
|
`Extract every checkbox task (- [ ] items) and order them to respect dependencies ` +
|
||||||
|
`(e.g. core Rust changes before WASM/CLI consumers, schema changes before UI). ` +
|
||||||
|
`For each task return: id (short slug like S1-step2), description (full step text), ` +
|
||||||
|
`planPath (which file it came from), techDomain (rust/extension/docs/cli/wasm).`,
|
||||||
|
{ schema: TASK_LIST_SCHEMA, label: 'pm-plan', phase: 'Plan' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`PM ordered ${taskList.tasks.length} tasks for sequential execution`)
|
||||||
|
|
||||||
|
phase('Execute')
|
||||||
|
await pipeline(
|
||||||
|
taskList.tasks,
|
||||||
|
(task) => agent(
|
||||||
|
`You are a senior developer on the ${release} release of Relicario.\n` +
|
||||||
|
`Repo: ${REPO}\n\n` +
|
||||||
|
`IMPORTANT: cd into ${REPO} before any git or cargo commands.\n\n` +
|
||||||
|
`Your task (${task.id}): ${task.description}\n` +
|
||||||
|
`Plan file for full context: ${task.planPath}\n` +
|
||||||
|
`Tech domain: ${task.techDomain}\n\n` +
|
||||||
|
`Instructions:\n` +
|
||||||
|
`1. Read the plan file for context on this specific step.\n` +
|
||||||
|
`2. Implement ONLY this step — do not run ahead to the next one.\n` +
|
||||||
|
`3. Run the relevant tests after your change (cargo test -p <crate> for Rust; pnpm build for extension).\n` +
|
||||||
|
`4. Commit with a conventional commit message scoped to the change.\n` +
|
||||||
|
`5. Report: what you did, test result (pass/fail), any blockers.`,
|
||||||
|
{ label: task.id, phase: 'Execute' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Advisory: checkbox hygiene ───────────────────────────────────────────
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Read each of these plan files from ${REPO}:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||||
|
`Count any lines still matching "- [ ]" (unticked checkboxes). ` +
|
||||||
|
`Log each unticked item with its file and line text. ` +
|
||||||
|
`This is advisory only — report findings but do not block or fail.`,
|
||||||
|
{ label: 'checkbox-check', phase: 'Verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
phase('Verify')
|
||||||
|
const verifyResult = await agent(
|
||||||
|
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||||
|
`Commands:\n` +
|
||||||
|
` cargo test\n` +
|
||||||
|
` cargo build --all-targets\n` +
|
||||||
|
` cargo clippy -- -D warnings\n` +
|
||||||
|
`Report pass/fail for each command. List every failure with its error message.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'full-verify', phase: 'Verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!verifyResult.allPass) {
|
||||||
|
log(`Verify FAILED — ${verifyResult.failures.length} failure(s): ${verifyResult.failures.join(' | ')}`)
|
||||||
|
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
|
||||||
|
return { status: 'verify-failed', failures: verifyResult.failures, summary: verifyResult.summary }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advisory: debug artifact scan ────────────────────────────────────────
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Run the following command from ${REPO}:\n` +
|
||||||
|
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
|
||||||
|
`Examine lines beginning with "+" (additions) in the diff output.\n` +
|
||||||
|
`Report any occurrences of:\n` +
|
||||||
|
` - dbg!( in Rust files (warn)\n` +
|
||||||
|
` - console.log( in TypeScript files (warn)\n` +
|
||||||
|
` - TODO or FIXME anywhere (warn)\n` +
|
||||||
|
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
|
||||||
|
`Log each finding with its file and line. This is advisory only — do not block.`,
|
||||||
|
{ label: 'debug-artifact-scan', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
phase('Finalize')
|
||||||
|
await agent(
|
||||||
|
`Update ${REPO}/STATUS.md to reflect the ${release} work that just completed.\n` +
|
||||||
|
`Mark any in-flight items as landed. Set what is now in flight next.\n` +
|
||||||
|
`Commit the STATUS.md update with message "docs: update STATUS for ${release} develop pass".`,
|
||||||
|
{ label: 'update-status', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Single-mode develop complete. Run action=release when ready to tag.`)
|
||||||
|
return { status: 'complete', mode: 'single', release }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MULTI MODE ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
phase('Plan')
|
||||||
|
const assignment = await agent(
|
||||||
|
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
|
||||||
|
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||||
|
`Decide how many dev streams are needed (one per major domain or plan, max 3). ` +
|
||||||
|
`Minimize cross-dev dependencies. For each dev assign: ` +
|
||||||
|
`letter (A/B/C), scope summary (2 sentences), task IDs they own, ` +
|
||||||
|
`out-of-scope task IDs (owned by other devs), primary techDomain, and which planFiles they need to read. ` +
|
||||||
|
`Also write a 2-sentence pmScope describing your oversight and review duties.`,
|
||||||
|
{ schema: ASSIGNMENT_SCHEMA, label: 'pm-assign', phase: 'Plan' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`PM assigned ${assignment.devCount} dev stream(s)`)
|
||||||
|
|
||||||
|
phase('Generate')
|
||||||
|
const allRoles = ['pm', ...assignment.devs.map(d => devRole(d.letter))].join(', ')
|
||||||
|
|
||||||
|
await parallel([
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Write a self-contained PM kickoff prompt to ${REPO}/${COORD_DIR}/${release}-pm-prompt.md.\n\n` +
|
||||||
|
`Release: ${release}\n` +
|
||||||
|
`PM scope: ${assignment.pmScope}\n` +
|
||||||
|
`Plans: ${manifest.plans.join(', ')}\n` +
|
||||||
|
`Dev roster:\n${assignment.devs.map(d => ` Dev-${d.letter}: ${d.scope}`).join('\n')}\n\n` +
|
||||||
|
`The file must include these sections in order:\n` +
|
||||||
|
`1. Role header ("You are the PM for the ${release} release of Relicario.")\n` +
|
||||||
|
`2. Working directory: ${REPO}\n` +
|
||||||
|
`3. Required reading: CLAUDE.md, all plan files listed above\n` +
|
||||||
|
`4. Authority: approve scope changes, review dev PRs, write CHANGELOG entry, drive doc updates, tag release (with user approval only)\n` +
|
||||||
|
`5. Boundaries: write NO feature code; NO destructive ops without user confirmation\n` +
|
||||||
|
`6. Relay server section: localhost:7331, your from="pm", tools: post_message/read_messages/list_pending, recipients: ${allRoles}. Include Python shim fallback.\n` +
|
||||||
|
`7. Dev roster with each dev letter, branch name (feature/${release}-dev-X), worktree path (${REPO}.dev-x), and scope\n` +
|
||||||
|
`8. Coordination protocol: DIRECTIVE block format, RELEASE STATUS rollup format\n` +
|
||||||
|
`9. PR review procedure (gh pr view / gh pr diff)\n` +
|
||||||
|
`10. Pre-tag checklist (all tests pass, CHANGELOG written, STATUS.md updated, all dev PRs merged)\n` +
|
||||||
|
`11. First action: read all required files, emit a RELEASE STATUS block confirming context absorbed, then check all dev inboxes\n` +
|
||||||
|
`Make every section concrete — the receiving Claude has zero prior context.`,
|
||||||
|
{ label: 'gen-pm', phase: 'Generate' }
|
||||||
|
),
|
||||||
|
|
||||||
|
...assignment.devs.map((dev) => () => agent(
|
||||||
|
`Write a self-contained Dev-${dev.letter} kickoff prompt to ${REPO}/${COORD_DIR}/${release}-dev-${dev.letter.toLowerCase()}-prompt.md.\n\n` +
|
||||||
|
`Release: ${release}\n` +
|
||||||
|
`Dev-${dev.letter} scope: ${dev.scope}\n` +
|
||||||
|
`Tasks owned: ${dev.tasks.join(', ')}\n` +
|
||||||
|
`Out of scope: ${dev.outOfScope.join(', ')}\n` +
|
||||||
|
`Tech domain: ${dev.techDomain}\n` +
|
||||||
|
`Plan files: ${dev.planFiles.join(', ')}\n\n` +
|
||||||
|
`The file must include these sections in order:\n` +
|
||||||
|
`1. Role header ("You are Dev-${dev.letter} for the ${release} release of Relicario.")\n` +
|
||||||
|
`2. Worktree setup commands (run these FIRST before anything else):\n` +
|
||||||
|
` git -C ${REPO} worktree add ${REPO}.dev-${dev.letter.toLowerCase()} -b feature/${release}-dev-${dev.letter.toLowerCase()}\n` +
|
||||||
|
` cd ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
|
||||||
|
`3. Working directory after setup: ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
|
||||||
|
`4. CRITICAL subagent rule: every subagent prompt MUST start with "cd ${REPO}.dev-${dev.letter.toLowerCase()} &&" — never rely on working-directory headers alone\n` +
|
||||||
|
`5. Required reading: CLAUDE.md, ${dev.planFiles.join(', ')}\n` +
|
||||||
|
`6. Execution mode: use superpowers:subagent-driven-development\n` +
|
||||||
|
`7. Scope: in-scope tasks (${dev.tasks.join(', ')}), out-of-scope (${dev.outOfScope.join(', ')})\n` +
|
||||||
|
`8. Hard rules from the plan (copy any HIGH-severity or acceptance-test constraints verbatim)\n` +
|
||||||
|
`9. Relay: localhost:7331, your from="${devRole(dev.letter)}", call read_messages before each task, post status/questions to "pm". Recipients: ${allRoles}. Include Python shim fallback.\n` +
|
||||||
|
`10. STATUS UPDATE format: Task / Status (COMPLETE|IN-PROGRESS|BLOCKED) / Notes (what + why) / Next — print locally AND post to pm via relay\n` +
|
||||||
|
`11. Final test commands for ${dev.techDomain}\n` +
|
||||||
|
`12. PR procedure: gh pr create targeting main, title "feat(${release}): Dev-${dev.letter} — <scope>"\n` +
|
||||||
|
`13. First action: run worktree setup, emit STATUS UPDATE "setup complete", start Task 1`,
|
||||||
|
{ label: `gen-dev-${dev.letter.toLowerCase()}`, phase: 'Generate' }
|
||||||
|
)),
|
||||||
|
|
||||||
|
])
|
||||||
|
|
||||||
|
// Check relay, start if needed
|
||||||
|
await agent(
|
||||||
|
`Check if the relay server is running on localhost:7331 by running: ` +
|
||||||
|
`curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1 && echo running || echo stopped\n\n` +
|
||||||
|
`If the output is "stopped", start it: ` +
|
||||||
|
`nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
|
||||||
|
`Then poll curl -sf http://127.0.0.1:7331/sse --max-time 1 once per second for up to 10s. ` +
|
||||||
|
`Report "relay ready" or "relay failed to start (check /tmp/relay-${release}.log)".`,
|
||||||
|
{ label: 'relay-check', phase: 'Generate' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Write a bash launch script to ${REPO}/${COORD_DIR}/${release}-launch.sh.\n\n` +
|
||||||
|
`Header comment: # Auto-generated by release workflow — ${release}\n` +
|
||||||
|
`set -e\n\n` +
|
||||||
|
`Section 1 — Relay health check and auto-start:\n` +
|
||||||
|
` if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then\n` +
|
||||||
|
` echo "[relay] already running"\n` +
|
||||||
|
` else\n` +
|
||||||
|
` echo "[relay] starting..." && nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
|
||||||
|
` for i in $(seq 1 10); do sleep 1; curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1 && echo "[relay] ready" && break || true; [ $i -eq 10 ] && echo "[relay] ERROR — check /tmp/relay-${release}.log" && exit 1; done\n` +
|
||||||
|
` fi\n\n` +
|
||||||
|
`Section 2 — tmux session. Session name is the release label.\n` +
|
||||||
|
` If tmux session already exists, attach and exit.\n` +
|
||||||
|
` Otherwise create a new session, then for each role (pm + each dev letter) create a named window\n` +
|
||||||
|
` that runs: claude\n` +
|
||||||
|
` After creating windows, print a prompt-paste cheatsheet showing which file to paste in each window.\n` +
|
||||||
|
` Then attach to the session.\n\n` +
|
||||||
|
`Devs: ${assignment.devs.map(d => 'Dev-' + d.letter).join(', ')}\n` +
|
||||||
|
`Prompt files in ${COORD_DIR}:\n` +
|
||||||
|
` PM: ${release}-pm-prompt.md\n` +
|
||||||
|
assignment.devs.map(d => ` Dev-${d.letter}: ${release}-dev-${d.letter.toLowerCase()}-prompt.md`).join('\\n') + '\\n\\n' +
|
||||||
|
`After writing the file, run: chmod +x ${REPO}/${COORD_DIR}/${release}-launch.sh`,
|
||||||
|
{ label: 'gen-launch-script', phase: 'Generate' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Prompts + launch script ready in ${COORD_DIR}/`)
|
||||||
|
log(`Run: ${REPO}/${COORD_DIR}/${release}-launch.sh`)
|
||||||
|
log(`(starts relay if needed, opens tmux session, prompts you which file to paste in each window)`)
|
||||||
|
|
||||||
|
return { status: 'prompts-ready', devCount: assignment.devCount, coordDir: COORD_DIR }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: debug ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'debug') {
|
||||||
|
if (!context) throw new Error('args.context required for action=debug — describe the failure or paste test output')
|
||||||
|
|
||||||
|
let currentContext = context
|
||||||
|
const MAX_ITERATIONS = 5
|
||||||
|
|
||||||
|
for (let i = 1; i <= MAX_ITERATIONS; i++) {
|
||||||
|
phase(`Debug iteration ${i}`)
|
||||||
|
|
||||||
|
const result = await agent(
|
||||||
|
`You are debugging a failure in Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
|
||||||
|
`Failure context:\n${currentContext}\n\n` +
|
||||||
|
`Use systematic debugging:\n` +
|
||||||
|
`1. Form a specific hypothesis about the root cause.\n` +
|
||||||
|
`2. Read the relevant source files and tests.\n` +
|
||||||
|
`3. Implement the minimal fix — no unrelated changes.\n` +
|
||||||
|
`4. Run the failing test(s) to confirm they now pass.\n` +
|
||||||
|
`5. Run cargo test to confirm no regressions.\n` +
|
||||||
|
`6. Commit the fix if clean.\n\n` +
|
||||||
|
`Return fixed=true if all tests pass, fixed=false with remainingFailures if not.`,
|
||||||
|
{ schema: DEBUG_RESULT_SCHEMA, label: `debug-iter-${i}` }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Iteration ${i}: ${result.summary}`)
|
||||||
|
|
||||||
|
if (result.fixed) {
|
||||||
|
log(`Fixed after ${i} iteration(s).`)
|
||||||
|
return { status: 'fixed', iterations: i, summary: result.summary }
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContext = result.remainingFailures || currentContext
|
||||||
|
log(`Still failing — next iteration with updated context`)
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Reached max iterations (${MAX_ITERATIONS}). Manual intervention needed.`)
|
||||||
|
return { status: 'max-iterations', lastContext: currentContext }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: verify ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'verify') {
|
||||||
|
phase('Verify')
|
||||||
|
const result = await agent(
|
||||||
|
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||||
|
` cargo test\n` +
|
||||||
|
` cargo build --all-targets\n` +
|
||||||
|
` cargo clippy -- -D warnings\n` +
|
||||||
|
`Report pass/fail for each. List every failure with its error text.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.allPass) {
|
||||||
|
log(`All checks pass.`)
|
||||||
|
} else {
|
||||||
|
log(`FAILED: ${result.failures.join(' | ')}`)
|
||||||
|
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: release ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'release') {
|
||||||
|
if (!release) throw new Error('args.release is required for action=release')
|
||||||
|
|
||||||
|
phase('Verify')
|
||||||
|
const verifyResult = await agent(
|
||||||
|
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||||
|
` cargo test\n` +
|
||||||
|
` cargo build --all-targets\n` +
|
||||||
|
` cargo clippy -- -D warnings\n` +
|
||||||
|
`Report pass/fail. List failures.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'pre-release-verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!verifyResult.allPass) {
|
||||||
|
log(`Tests failing — cannot cut release. Fix first with action=debug.`)
|
||||||
|
return { status: 'blocked', failures: verifyResult.failures }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Version + tag checks ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const versionCheck = await agent(
|
||||||
|
`Read ${REPO}/Cargo.toml and all files matching ${REPO}/crates/*/Cargo.toml.\n` +
|
||||||
|
`For each file, extract the version field from the [package] section.\n` +
|
||||||
|
`Check whether all extracted versions are identical.\n` +
|
||||||
|
`Then run: git -C ${REPO} tag -l "${release}"\n` +
|
||||||
|
`Set tagExists=true if the output is non-empty (the tag already exists), false otherwise.\n` +
|
||||||
|
`Return consistent (true if all versions match), versions (list of all extracted version strings), ` +
|
||||||
|
`conflicts (list of "file: version" strings for any that differ from the majority), and tagExists.`,
|
||||||
|
{ schema: VERSION_CHECK_SCHEMA, label: 'version-tag-check', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!versionCheck.consistent) {
|
||||||
|
log(`FAIL [version-tag-check]: version mismatch across crates — ${versionCheck.conflicts.join(' | ')}`)
|
||||||
|
return { status: 'blocked', reason: 'version-mismatch' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionCheck.tagExists) {
|
||||||
|
log(`FAIL [version-tag-check]: tag "${release}" already exists — cannot re-tag`)
|
||||||
|
return { status: 'blocked', reason: 'tag-exists' }
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[version-tag-check]: Versions consistent (${versionCheck.versions[0]}), tag available`)
|
||||||
|
|
||||||
|
// ── Advisory: debug artifact scan ──────────────────────────────────────────
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Run the following command from ${REPO}:\n` +
|
||||||
|
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
|
||||||
|
`Examine lines beginning with "+" (additions) in the diff output.\n` +
|
||||||
|
`Report any occurrences of:\n` +
|
||||||
|
` - dbg!( in Rust files (warn)\n` +
|
||||||
|
` - console.log( in TypeScript files (warn)\n` +
|
||||||
|
` - TODO or FIXME anywhere (warn)\n` +
|
||||||
|
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
|
||||||
|
`Log each finding with its file and line. This is advisory only — do not block.`,
|
||||||
|
{ label: 'debug-artifact-scan', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
phase('Finalize')
|
||||||
|
await agent(
|
||||||
|
`Cut the ${release} release for Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
|
||||||
|
`Steps (in order):\n` +
|
||||||
|
`1. Run: git log $(git describe --tags --abbrev=0)..HEAD --oneline\n` +
|
||||||
|
` Use that output to write a ${release} section in CHANGELOG.md — user-facing language, grouped by type.\n` +
|
||||||
|
`2. Update STATUS.md: mark ${release} as released, set what is next.\n` +
|
||||||
|
`3. Update ROADMAP.md: check off the ${release} milestone.\n` +
|
||||||
|
`4. Commit those doc updates: git commit -m "release: ${release}"\n` +
|
||||||
|
`5. Create annotated tag: git tag -a ${release} -m "Release ${release}"\n` +
|
||||||
|
`6. STOP. Print the tag SHA and the push command, then ask the user to confirm before pushing.\n` +
|
||||||
|
` Do NOT run git push or git push --tags without explicit user confirmation.`,
|
||||||
|
{ label: 'cut-release', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
return { status: 'tagged', release, note: 'Confirm and push manually.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: cleanup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'cleanup') {
|
||||||
|
phase('Cleanup')
|
||||||
|
|
||||||
|
const result = await agent(
|
||||||
|
`Run: git -C ${REPO} worktree list\n` +
|
||||||
|
`Run: git -C ${REPO} branch --merged main\n\n` +
|
||||||
|
`For each worktree listed (skip the main checkout at ${REPO} itself):\n` +
|
||||||
|
` - If its branch appears in the merged list:\n` +
|
||||||
|
` Run: git -C ${REPO} worktree remove --force <path>\n` +
|
||||||
|
` Run: git -C ${REPO} branch -d <branch> (lowercase -d only, never -D)\n` +
|
||||||
|
` Add to removed: [{path, branch}]\n` +
|
||||||
|
` - If its branch does NOT appear in the merged list:\n` +
|
||||||
|
` Add to kept: [{path, branch, reason: "unmerged"}]\n\n` +
|
||||||
|
`Return removed (worktrees that were deleted) and kept (worktrees that were left in place).`,
|
||||||
|
{ schema: CLEANUP_RESULT_SCHEMA, label: 'cleanup', phase: 'Cleanup' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Cleanup removed ${result.removed.length} worktree(s):`)
|
||||||
|
for (const w of result.removed) {
|
||||||
|
log(` removed: ${w.path} (${w.branch})`)
|
||||||
|
}
|
||||||
|
log(`Cleanup kept ${result.kept.length} worktree(s):`)
|
||||||
|
for (const w of result.kept) {
|
||||||
|
log(` kept: ${w.path} (${w.branch}) — ${w.reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'done', removed: result.removed.length, kept: result.kept.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Unknown action: "${action}". Valid: develop, debug, verify, release, preflight, cleanup`)
|
||||||
|
return { status: 'error', action }
|
||||||
309
CHANGELOG.md
309
CHANGELOG.md
@@ -1,5 +1,310 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.8.1 — 2026-06-20 — org item-type parity + collection-scoped attachments
|
||||||
|
|
||||||
|
Brings `relicario org add` / `relicario org edit` to **full item-type parity** with the
|
||||||
|
personal vault: the org surface now supports **all 7 item types** (previously Login /
|
||||||
|
SecureNote / Identity only), adds collection-scoped attachment storage for Document
|
||||||
|
items, and grant-scopes attachment write paths in the pre-receive hook — closing a latent
|
||||||
|
authorization gap. Secrets are entered via interactive prompts by default, with `--*-stdin`
|
||||||
|
escape hatches for non-interactive scripting. Tracked under
|
||||||
|
`docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`.
|
||||||
|
|
||||||
|
> **⚠️ Coordinated server redeploy required.** The `relicario-server` pre-receive hook
|
||||||
|
> (now `0.1.1`) must be rebuilt and redeployed for attachment writes to be grant-scoped in
|
||||||
|
> production. Until the updated hook is installed, `attachments/…` pushes remain
|
||||||
|
> `Unrestricted` (gated only by the per-commit member-signature check).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Shared `item_build` CLI module** (`crates/relicario-cli/src/commands/item_build.rs`):
|
||||||
|
centralizes per-type secret resolution, item builders (`build_*`), and interactive edit
|
||||||
|
helpers (`edit_*`) consumed by **both** the personal and org command surfaces, eliminating
|
||||||
|
the prior personal↔org builder duplication.
|
||||||
|
- **Org `add` / `edit` parity for Card, Key, TOTP, and Document** — `relicario org add` now
|
||||||
|
creates all 7 item types; `relicario org edit` is interactive per-type ("blank to keep",
|
||||||
|
field-history capture) instead of flat flags.
|
||||||
|
- **`--*-stdin` secret flags** on personal and org `add` for non-interactive entry of
|
||||||
|
passwords, card number/CVV/PIN, key material, TOTP secrets, and note bodies.
|
||||||
|
- **Collection-scoped org attachment storage** (`crates/relicario-cli/src/org_session.rs`):
|
||||||
|
attachments stored at `attachments/<slug>/<item-id>/<att-id>.enc` with a default
|
||||||
|
per-attachment cap (10 MiB, mirroring the personal default at
|
||||||
|
`crates/relicario-core/src/settings.rs`). `org add document --file`, `org edit --file`
|
||||||
|
(replace), and `org purge` (removes the item's attachment directory) round-trip with
|
||||||
|
git-status-clean staging.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Grant-scoped attachment writes** (`relicario-server` `0.1.1`): `classify_path` now
|
||||||
|
recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (exactly 3 path segments, `.`-free
|
||||||
|
slug guard) as `Item { collection }`, bringing attachment writes under the same grant +
|
||||||
|
slug-existence check as `items/` blobs. Previously such paths fell through to
|
||||||
|
`Unrestricted`. The Document source plaintext is read into a `Zeroizing` buffer and wiped
|
||||||
|
after encryption. See `docs/SECURITY.md`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Personal `add secure-note` `--body-prompt` flag renamed to `--body-stdin` (unified
|
||||||
|
multiline-secret model).
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- Updated cli `ARCHITECTURE.md`, `docs/FORMATS.md` (org attachment layout + cap citation),
|
||||||
|
`docs/SECURITY.md`, `STATUS.md`, and `ROADMAP.md`. New
|
||||||
|
`docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md` is the forward
|
||||||
|
plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension
|
||||||
|
gaps). End-user `user_docs/` guide lands as a fast-follow.
|
||||||
|
|
||||||
|
## v0.8.0 — 2026-06-20 — enterprise org vault
|
||||||
|
|
||||||
|
Git-native multi-user **org vaults**: a separate org git repository alongside each
|
||||||
|
member's personal vault, with a 256-bit org master key ECIES-wrapped per member to
|
||||||
|
their ed25519 device key, collection-scoped item storage, role-based access, and a
|
||||||
|
signature-verifying pre-receive hook that makes least-privilege enforcement
|
||||||
|
server-side. Tracked under `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **relicario-core `org` module** (`crates/relicario-core/src/org.rs`): org types
|
||||||
|
(`OrgId`, `MemberId`, `OrgRole`, `OrgMember`/`OrgMembers`, `CollectionDef`/
|
||||||
|
`OrgCollections`, `OrgMeta`, `OrgManifest`/`OrgManifestEntry`) and ECIES X25519
|
||||||
|
key wrap/unwrap (`generate_org_key`, `wrap_org_key`, `unwrap_org_key`) — ed25519→
|
||||||
|
X25519 via RFC 7748 clamp, domain-separated `SHA-256(dh || eph_pk || rcpt_pk)` KDF,
|
||||||
|
XChaCha20-Poly1305 inner cipher, all key material in `Zeroizing`. Adds
|
||||||
|
`encrypt_org_manifest` / `decrypt_org_manifest` vault wrappers. New dependencies:
|
||||||
|
`x25519-dalek 2` (`static_secrets`) in core, `ssh-key 0.6` in core and CLI.
|
||||||
|
- **relicario-server org mode**: `verify-org-commit` (commit-signature verification
|
||||||
|
against `members.json` ed25519 keys, path-scoped role/grant authorization,
|
||||||
|
owner-only elevation judged on the signer's pre-commit role, schema-version
|
||||||
|
monotonicity) and `generate-org-hook`; new `[lib]` target (`classify_path`,
|
||||||
|
`extract_schema_version`). Audit trail on every push carries verified-signer
|
||||||
|
attribution; commits whose signer cannot be matched are flagged `TAMPERED`.
|
||||||
|
- **relicario-cli org admin commands**: `org init`, `add-member` / `remove-member` /
|
||||||
|
`set-role` (owner-only escalation guard), `create-collection` / `grant` / `revoke`,
|
||||||
|
`rotate-key` (re-encrypts every item blob + manifest under a fresh org key),
|
||||||
|
`transfer-ownership`, `delete-org` (local tombstone; hook blocks pushing a
|
||||||
|
protected-file deletion), `status` / `audit`. Org commits are signed
|
||||||
|
(`org_git_run` preserves signing).
|
||||||
|
- **relicario-cli org item CRUD**: `org add` (Login, SecureNote, Identity — each
|
||||||
|
collection-scoped and grant-enforced), `org get <query> [--show]` (secrets masked
|
||||||
|
by default; renders Login/SecureNote/Identity/Card/Document/Totp), `org list
|
||||||
|
[--trashed]` (manifest filtered to your collection grants), `org edit <query>`
|
||||||
|
(flag-driven field updates for login/note/identity fields), `org rm` / `org restore`
|
||||||
|
/ `org purge` (soft-delete lifecycle). Audit actions emitted: `item-create`,
|
||||||
|
`item-update`, `item-delete`, `item-restore`, `item-purge`.
|
||||||
|
|
||||||
|
### Deferred
|
||||||
|
- `org add` / `org edit` parity for Card, SshKey, Document, and Totp item types
|
||||||
|
(only Login, SecureNote, Identity supported today; `org get` and `org list` can
|
||||||
|
display all types already present in the vault).
|
||||||
|
- Extension org switch + read-only browse parity (Dev-D follow-up).
|
||||||
|
- Extension org writes.
|
||||||
|
- Phase-2 features: SSO/LDAP provisioning, read audit trail, per-collection subkeys
|
||||||
|
(the current shared org master key scopes *writes* via the hook and *read access*
|
||||||
|
via manifest filtering, but does not cryptographically isolate collections from one
|
||||||
|
another — a member who obtains the org key can decrypt any blob), HTTP management
|
||||||
|
plane.
|
||||||
|
|
||||||
|
## v0.7.0 — 2026-06-01
|
||||||
|
|
||||||
|
Completes the extension restructure (Plan C) begun under v0.6.0. Phases
|
||||||
|
1/2/5 (StateHost typing, SW storage extract, the P2 cluster) shipped
|
||||||
|
2026-05-30; this tag adds the remaining three phases — executed as three
|
||||||
|
parallel worktree streams under PM coordination — which eliminate the
|
||||||
|
two steepest learning cliffs in the extension and close the last
|
||||||
|
`relicario status` CLI/extension parity gap. No crypto, wire-format, or
|
||||||
|
Rust-API changes; this is an internal-architecture + one-feature release.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`relicario status` parity in the extension.** New `get_vault_status`
|
||||||
|
service-worker message returns a cached sync summary
|
||||||
|
`{ ahead, behind, lastSyncAt, pendingItems }` with no network call —
|
||||||
|
`ahead`/`behind`/`lastSyncAt` read straight off the cached git-host
|
||||||
|
state (populated by the `sync` handler), `pendingItems` a live count of
|
||||||
|
active (non-trashed) manifest entries. A sidebar-footer status indicator
|
||||||
|
(`vault-status.ts` → `renderStatusIndicator`) renders `N pending` /
|
||||||
|
`N ahead` / `N behind` / `in sync` plus a `last sync …` / `never synced`
|
||||||
|
line, refreshed on mount and on a manual `↻` button — no timer polling,
|
||||||
|
matching the no-network-without-user-intent discipline.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Setup wizard crypto moved into the service worker.** The wizard no
|
||||||
|
longer imports `relicario-wasm` or orchestrates the master key directly.
|
||||||
|
New `create_vault` / `attach_vault` SW handlers own the full flow
|
||||||
|
(image-secret embed/extract, unlock, manifest+settings encrypt + push,
|
||||||
|
`register_device` + `addDevice`, persist config + reference image,
|
||||||
|
`session.setCurrent`); on failure the SessionHandle is locked then freed,
|
||||||
|
with ownership transferring only on success. `setup.ts` collapses from
|
||||||
|
~1230 LOC to a 58-LOC UI-only shell; the six render/attach step pairs
|
||||||
|
become a `SetupStep` registry in the new `setup/setup-steps.ts`. Adds
|
||||||
|
`clearWizardState` (bound to `beforeunload` and `goto('mode')`) to wipe
|
||||||
|
sensitive Uint8Array fields when the wizard is abandoned. The
|
||||||
|
non-extension copy-vault-config-JSON escape hatch is preserved.
|
||||||
|
- **`vault.ts` split from a 1037-LOC monolith to 194 LOC of routing +
|
||||||
|
state.** Extracted into five focused modules — `vault-shell` (DOM
|
||||||
|
scaffolding, color-scheme, onMessage wiring), `vault-sidebar` (category
|
||||||
|
nav, 80ms debounced search, bottom nav, status-indicator footer),
|
||||||
|
`vault-list` (list + row rendering), `vault-drawer` (open/close/render +
|
||||||
|
`ensureDrawerClosedForRoute`), `vault-form-wrapper` (wrapped form + sticky
|
||||||
|
bar) — plus two support modules for an acyclic split (`vault-context`,
|
||||||
|
the VaultController contract; `vault-router`, hash routing + pane
|
||||||
|
dispatch).
|
||||||
|
- **`vault_locked` RPC intercept unified.** Lifted out of `vault.ts` into
|
||||||
|
the `sendMessage` wrapper in `shared/state.ts`, so both popup and
|
||||||
|
vault-tab surfaces share one lock-redirect path.
|
||||||
|
- **`state.gitHost` now nulled on explicit lock**, symmetric with the
|
||||||
|
session-timer expiry path, so the new status indicator can't surface a
|
||||||
|
stale `lastSyncAt` after a lock → re-unlock within one service-worker
|
||||||
|
lifetime.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- Three-stream parallel execution (Dev-A Phase 3, Dev-B Phase 4, Dev-C
|
||||||
|
Phase 6) coordinated via the relay message bus; merges sequenced
|
||||||
|
Phase 3 → 4 → 6 with per-phase done-criteria verification.
|
||||||
|
- Final merged-tree validation: **423/423** vitest (62 files);
|
||||||
|
`npm run build:all` clean for both Chrome and Firefox targets (only the
|
||||||
|
pre-existing ~4 MB WASM asset-size warning). Task 7.1 done-criteria
|
||||||
|
sweep all green. No change to `wasm.d.ts`.
|
||||||
|
|
||||||
|
## 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 `⤴`.
|
||||||
|
- **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
|
## v0.5.0 — 2026-05-02
|
||||||
|
|
||||||
Three release trains roll into one tag — backup/restore + LastPass
|
Three release trains roll into one tag — backup/restore + LastPass
|
||||||
@@ -135,12 +440,12 @@ two confirmed bugs).
|
|||||||
the `.form-grid` cards above. Removes the visual rhythm break at the
|
the `.form-grid` cards above. Removes the visual rhythm break at the
|
||||||
2-col → full-width transition. The popup surface is unchanged.
|
2-col → full-width transition. The popup surface is unchanged.
|
||||||
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
|
- **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);
|
`relicario-server` pre-receive hook crate is no longer invisible);
|
||||||
`CLAUDE.md` project tree and roadmap reflect current state;
|
`CLAUDE.md` project tree and roadmap reflect current state;
|
||||||
`docs/SECURITY.md` names the server crate and its `verify-commit` /
|
`docs/SECURITY.md` names the server crate and its `verify-commit` /
|
||||||
`generate-hook` subcommands and notes the without-the-hook-it's-
|
`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
|
parallel artifact in the vault-creation flow; the foundational
|
||||||
design spec gains a "historical" status banner pointing readers at
|
design spec gains a "historical" status banner pointing readers at
|
||||||
the current docs.
|
the current docs.
|
||||||
|
|||||||
75
CLAUDE.md
75
CLAUDE.md
@@ -86,10 +86,77 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
|||||||
|
|
||||||
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
|
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. Once a plan exists, execute it via the release workflow (see **Release lifecycle** below) — not directly via subagent-driven-development or executing-plans unless the workflow is unavailable.
|
||||||
|
|
||||||
## Roadmap
|
**Product gate.** After brainstorming a new release spec — before handing it to writing-plans — run `/product-expert review <spec-path>` for a product/market-fit gut-check (PROCEED / RESCOPE / CUT / PIVOT). The `product-expert` skill also runs standalone (`/product-expert`) any time you want to audit the whole roadmap and get PM-ready directives for modifications or pivots; add `deep` for live competitive web research. It only advises — you stay the decision-maker.
|
||||||
|
|
||||||
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.
|
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
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Release lifecycle
|
||||||
|
|
||||||
|
The `release` workflow (`.claude/workflows/release.js`) is the **default execution layer** for all dev work. Invoke it via the `Workflow` tool or the `/release` skill. Full reference: `docs/superpowers/RELEASE-WORKFLOW.md`.
|
||||||
|
|
||||||
|
### Standard actions
|
||||||
|
|
||||||
|
| Action | When | How |
|
||||||
|
|--------|------|-----|
|
||||||
|
| `develop` + `mode:"single"` | Implement a plan; phone/remote; fire-and-forget | `Workflow({name:"release", args:{action:"develop", mode:"single", release:"<label>"}})` |
|
||||||
|
| `develop` + `mode:"multi"` | Parallel streams; at PC; PM supervises devs | `Workflow({name:"release", args:{action:"develop", mode:"multi", release:"<label>"}})` |
|
||||||
|
| `debug` | Fix a failing test or broken feature after manual testing | `Workflow({name:"release", args:{action:"debug", context:"<paste failure>"}})` |
|
||||||
|
| `verify` | Confirm tests pass before releasing | `Workflow({name:"release", args:{action:"verify"}})` |
|
||||||
|
| `release` | Cut and tag a version | `Workflow({name:"release", args:{action:"release", release:"<label>"}})` |
|
||||||
|
|
||||||
|
### Execution defaults
|
||||||
|
|
||||||
|
- **Single-plan work** → `mode:"single"`. One agent works through tasks sequentially; updates `STATUS.md` automatically on completion.
|
||||||
|
- **Multi-plan or multi-phase work** → `mode:"multi"`. PM agent reads plans, assigns dev streams (up to 6), generates prompt files + a `<release>-launch.sh` in `docs/superpowers/coordination/`. Run the launch script — it starts the relay and opens a tmux session.
|
||||||
|
- **Debugging** → always `action:"debug"`. Never hand-fix without at least trying the debug loop first.
|
||||||
|
- **Releasing** → always `action:"release"`. It verifies first, writes CHANGELOG, tags, and stops before push.
|
||||||
|
|
||||||
|
### Multi-agent relay
|
||||||
|
|
||||||
|
The relay server (`tools/relay/`) supports roles `pm`, `dev-a` through `dev-f`. The launch script starts it automatically. If you need to start it manually: `cd tools/relay && ./start.sh`. Protocol reference: `docs/superpowers/coordination/RELAY.md`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
5. **Pre-flight before develop.** Before running `action:"develop"` on any release, run `action:"preflight"` first. If preflight reports FAIL (baseline not green or version mismatch), fix the failure before proceeding. WARN results (orphaned worktrees, partially-done plan) require a judgement call — acknowledge them explicitly before proceeding.
|
||||||
|
|
||||||
|
6. **Cleanup after every lift.** Once all PRs for a release are merged into main, run `Workflow({name:"release", args:{action:"cleanup"}})` to remove the lift's worktrees and feature branches. Stale worktrees accumulate silently and create confusion for the next lift's branch-collision check.
|
||||||
|
|||||||
24
Cargo.lock
generated
24
Cargo.lock
generated
@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.5.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -2166,17 +2166,20 @@ dependencies = [
|
|||||||
"clap_complete",
|
"clap_complete",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"ed25519-dalek",
|
||||||
"hex",
|
"hex",
|
||||||
"image",
|
"image",
|
||||||
"predicates",
|
"predicates",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"rand",
|
"rand",
|
||||||
|
"regex",
|
||||||
"relicario-core",
|
"relicario-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
"rqrr",
|
"rqrr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"ssh-key",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"url",
|
"url",
|
||||||
@@ -2185,7 +2188,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.5.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2209,6 +2212,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
"url",
|
"url",
|
||||||
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zstd",
|
"zstd",
|
||||||
"zxcvbn",
|
"zxcvbn",
|
||||||
@@ -2216,7 +2220,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-server"
|
name = "relicario-server"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
@@ -2231,7 +2235,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.5.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
@@ -3709,6 +3713,18 @@ version = "0.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x25519-dalek"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"rand_core",
|
||||||
|
"serde",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Architecture overview — Relicario
|
# 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.
|
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:
|
> 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-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md)
|
||||||
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md)
|
> - [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md)
|
||||||
> - [extension/ARCHITECTURE.md](../../extension/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*.
|
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
||||||
|
|
||||||
@@ -145,11 +147,25 @@ The threat model differs by codebase. This is the per-secret per-codebase reside
|
|||||||
| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it |
|
| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it |
|
||||||
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes |
|
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes |
|
||||||
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
|
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
|
||||||
|
| Org master key (256-bit, random) | `Zeroizing<[u8;32]>` during `wrap_org_key`/`unwrap_org_key` (never derived from a passphrase) | `UnlockedOrgVault.org_key` for one CLI invocation; recovered by unwrapping `keys/<member-id>.enc` with the device ed25519 seed | TODO (extension follow-up) | Never sees it |
|
||||||
|
|
||||||
|
The org master key is **never escrowed**: each member holds it ECIES-wrapped to their device key (`keys/<member-id>.enc`); an owner can always re-wrap it to a replacement device key, so there is no central key store to compromise. See `docs/CRYPTO.md` (Org-key ECIES wrap/unwrap) and `docs/FORMATS.md` (Org vault repo formats).
|
||||||
|
|
||||||
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything.
|
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything.
|
||||||
|
|
||||||
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op.
|
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op.
|
||||||
|
|
||||||
|
## Org vault (enterprise, in progress)
|
||||||
|
|
||||||
|
The enterprise org vault is a **second git repository** alongside each member's personal vault, with its own schema (`org.json` / `members.json` / `collections.json` / `keys/<member-id>.enc` / `manifest.enc` / `items/<collection-slug>/<item-id>.enc`). It reuses the same `relicario-core` AEAD; the only new crypto is the per-member ECIES key wrap. Cross-codebase additions:
|
||||||
|
|
||||||
|
- **relicario-core** gains the `org` module (`org.rs`) and the `x25519-dalek = { version = "2", features = ["static_secrets"] }` dependency (`crates/relicario-core/Cargo.toml:19`); `ssh-key` 0.6 is already present (`:20`).
|
||||||
|
- **relicario-cli** gains `org_session.rs` + `commands/org.rs` and the `ssh-key = "0.6"` dependency (`crates/relicario-cli/Cargo.toml:33`).
|
||||||
|
- **relicario-server** gains an **org mode**: a new `[lib]` target (`classify_path`, `extract_schema_version`) plus the `verify-org-commit` and `generate-org-hook` subcommands — a signature-verifying, path-scoped pre-receive hook (see `docs/SECURITY.md`).
|
||||||
|
- **extension** org switch + read parity is a tracked follow-up (Dev-D) — `TODO (extension follow-up)`.
|
||||||
|
|
||||||
|
Status: the backend is complete on `main` — core (A) org module, server hook (C), and the full CLI (all 19 `org` subcommands incl. item CRUD) are merged. Deferred: `org add`/`edit` parity for Card/Key/Document/Totp (Login/SecureNote/Identity ship today), and the extension org switch + read parity (`TODO (extension follow-up)`, Dev-D).
|
||||||
|
|
||||||
## Build matrix
|
## Build matrix
|
||||||
|
|
||||||
| Target | Tool | Output | When to run |
|
| Target | Tool | Output | When to run |
|
||||||
@@ -196,10 +212,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 |
|
| If you're working on... | Start with |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/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 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 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 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 |
|
| 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 |
|
| 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` |
|
| 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 +227,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|
|||||||
## Stale spec docs
|
## 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).
|
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.
|
||||||
232
LICENSE
Normal file
232
LICENSE
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
63
README.md
63
README.md
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
# Relicario
|
# 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.
|
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.
|
The server only ever sees opaque ciphertext. There is nothing else going on. This README is the security proof.
|
||||||
@@ -89,6 +91,12 @@ relicario list
|
|||||||
# Sync with your git remote
|
# Sync with your git remote
|
||||||
relicario sync
|
relicario sync
|
||||||
|
|
||||||
|
# Pack the vault into a single encrypted backup file
|
||||||
|
relicario backup export -o vault.relbak
|
||||||
|
|
||||||
|
# Print a recovery QR for your image_secret (see "Recovery" below)
|
||||||
|
relicario recovery-qr generate
|
||||||
|
|
||||||
# Generate a random password
|
# Generate a random password
|
||||||
relicario generate -l 32
|
relicario generate -l 32
|
||||||
```
|
```
|
||||||
@@ -108,34 +116,30 @@ The embedding survives:
|
|||||||
|
|
||||||
This means your reference image can live on your Instagram, your personal website, or anywhere else. It's useless without your passphrase.
|
This means your reference image can live on your Instagram, your personal website, or anywhere else. It's useless without your passphrase.
|
||||||
|
|
||||||
|
## Recovery: what if I lose my reference image?
|
||||||
|
|
||||||
|
Without your reference image, the vault is undecryptable — that's the security model. But it also makes a lost or corrupted image a single point of failure.
|
||||||
|
|
||||||
|
The mitigation is the **recovery QR**: a printable QR code that wraps your image secret behind a separate recovery passphrase you choose. If you ever lose access to the reference JPEG, scan or transcribe the QR, provide the recovery passphrase, and recover the 256-bit image secret. Combined with your normal vault passphrase, this restores access to the vault.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Print a recovery QR (after the vault is unlocked).
|
||||||
|
# You'll be prompted for a separate recovery passphrase.
|
||||||
|
relicario recovery-qr generate
|
||||||
|
|
||||||
|
# Recover the image_secret from a stored QR payload.
|
||||||
|
relicario recovery-qr unwrap
|
||||||
|
```
|
||||||
|
|
||||||
|
The QR payload is an XChaCha20-Poly1305 envelope keyed by Argon2id over a domain-separated input (prefixed with `b"relicario-recovery-v1\0"`), so even if you reuse your vault passphrase as your recovery passphrase, the wrap key cannot collide with a vault master key. Both salt and nonce are freshly randomized per call, so two QRs printed from the same passphrase yield different bytes — the printed copy doesn't leak whether you've printed others.
|
||||||
|
|
||||||
|
Recommended practice: print the QR, store it offline (safe, deposit box), and forget about it. The recovery passphrase is what protects the printed copy from being useful to someone who finds it.
|
||||||
|
|
||||||
## Architecture
|
## 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/
|
|
||||||
├── 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
|
|
||||||
│ │ └── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
`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
|
### Crypto primitives
|
||||||
|
|
||||||
@@ -206,6 +210,7 @@ The binary is at `target/release/relicario`.
|
|||||||
- [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP
|
- [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP
|
||||||
- [x] Secure document storage (encrypted file attachments)
|
- [x] Secure document storage (encrypted file attachments)
|
||||||
- [x] Backup & restore (`.relbak` encrypted envelope)
|
- [x] Backup & restore (`.relbak` encrypted envelope)
|
||||||
|
- [x] Recovery QR (paper-printable image_secret backup with separate passphrase)
|
||||||
- [x] LastPass CSV import
|
- [x] LastPass CSV import
|
||||||
- [x] Device authentication (ed25519 commit signing + pre-receive hook)
|
- [x] Device authentication (ed25519 commit signing + pre-receive hook)
|
||||||
- [ ] Import from Bitwarden / 1Password
|
- [ ] Import from Bitwarden / 1Password
|
||||||
@@ -215,8 +220,12 @@ The binary is at `target/release/relicario`.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
GPL-3.0-or-later — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Built by [Aaron Lee](https://adlee.work). Design spec and threat model in `docs/superpowers/specs/`.
|
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.
|
||||||
|
|||||||
45
ROADMAP.md
Normal file
45
ROADMAP.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 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.8.1** *(2026-06-20, tag pending PM)* | **Org item-type parity + collection-scoped attachments + grant-scoped hook** (`4c0a289`, four parallel streams): `relicario org add`/`edit` now cover **all 7 item types** — Card/Key/Totp (Dev-B `6e73c5e`) and Document (Dev-C `4c0a289`) on the shared `item_build` foundation (Dev-A `b09e0ce`); org attachments stored collection-scoped at `attachments/<slug>/<item-id>/<att-id>.enc` with a default cap (Dev-C); `relicario-server` `classify_path` grant-scopes those attachment writes (Dev-D `db4e05a`, server `0.1.1` — **requires pre-receive hook redeploy**). **Still deferred:** extension org read/write (forward plan: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`); org phase 2. |
|
||||||
|
| v0.8.0 *(2026-06-20)* | **Enterprise org vault — backend complete** (`7392795`): relicario-core `org` module (ECIES X25519 key wrap/unwrap, `OrgRole`/`OrgMember`/`OrgManifest` types, `filter_for_member`, `schema_version: 1`); relicario-server org hook (`verify-org-commit`: signature verification, path-scoped authz, `enforce_owner_only_elevation` on parent role, `enforce_schema_monotonicity`, `generate-org-hook`, new `[lib]` target); relicario-cli — all 19 `relicario org` subcommands: init, add-member/remove-member/set-role, create-collection/grant/revoke, rotate-key (re-encrypts all blobs), transfer-ownership, delete-org, status, audit, and item CRUD (add/get/list/edit/rm/restore/purge). Org item-type parity for Card/Key/Document/Totp shipped subsequently in v0.8.1; extension org parity + phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane) remain deferred. |
|
||||||
|
| v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap |
|
||||||
|
| 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 2026-05-04 architecture-review specs are shipped; the enterprise org vault backend (v0.8.0) and org item-type parity + collection-scoped attachments (v0.8.1) are shipped. Forward plan for extension parity: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`. Pending items in rough priority order:
|
||||||
|
|
||||||
|
- **Extension org parity — read** — org switch + collection-filtered browse in the popup/vault tab (Dev-D, deferred)
|
||||||
|
- **Extension org parity — write** — `org add`/`edit`/`rm` from the extension (Plan B-2; the CLI side reached all-7-type org write in v0.8.1, so this is unblocked CLI-side)
|
||||||
|
- **Personal-side extension gaps** — favorites UI, group/tag/filter editing across all type forms, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching (per the parity gap analysis)
|
||||||
|
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||||
|
|
||||||
|
## Medium-term
|
||||||
|
|
||||||
|
_(promote here once specced)_
|
||||||
|
|
||||||
|
- **Org vault phase 2** — SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation per collection), HTTP management plane
|
||||||
|
|
||||||
|
## 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`.
|
||||||
191
STATUS.md
Normal file
191
STATUS.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# 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:** **v0.8.1 — org item-type parity — COMPLETE (on `main` `4c0a289`; tag pending PM).** All four parallel streams merged: shared item-build foundation + personal add/edit refactor (Dev-A, `b09e0ce`); org add/edit parity for Card/Key/Totp (Dev-B, `6e73c5e`); org Document + collection-scoped attachment storage (Dev-C, `4c0a289`); grant-scoped attachment write-path hook (Dev-D, `db4e05a`). See the v0.8.1 landing section below.
|
||||||
|
|
||||||
|
## What landed on main since the v0.5.0 version bump
|
||||||
|
|
||||||
|
### v0.8.1 — org item-type parity + collection-scoped attachments + grant-scoped hook (merged 2026-06-20, `4c0a289`)
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md`; plan: `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`. Four parallel streams under PM coordination (relay-bus):
|
||||||
|
|
||||||
|
- **Dev-A — shared item-build foundation** (merge `b09e0ce`): `commands/item_build.rs` (shared secret-resolution, type parsers, per-type `build_*`/`edit_*` helpers, `push_history`); personal `add`/`edit` refactored onto it; personal `--*-stdin` flags for non-interactive scripting/tests.
|
||||||
|
- **Dev-B — org Card/Key/Totp parity** (merge `6e73c5e`): `OrgAddKind` gains Card/Key/Totp; `org edit` becomes per-type interactive dispatch (the old "login/secure-note/identity only" bail is gone).
|
||||||
|
- **Dev-C — org Document + collection-scoped attachments** (merge `4c0a289`): `OrgAddKind::Document`; `org_session.rs` attachment storage (`attachment_path`/`save_attachment`/`load_attachment`/`remove_item_attachments`) writing `attachments/<slug>/<item-id>/<att-id>.enc`; default org attachment cap; `org add document --file` + `org edit --file`; purge removes the item's attachment dir.
|
||||||
|
- **Dev-D — grant-scoped attachment hook** (merge `db4e05a`): `relicario-server` `classify_path` recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (3 segments, slug-only `.`-free guard) as `Item { collection }`, converting attachment writes from `Unrestricted` to grant-scoped — closing a latent authz gap. Bumped `relicario-server` to 0.1.1; `docs/SECURITY.md` documents the required pre-receive hook redeploy.
|
||||||
|
|
||||||
|
Result: `relicario org add`/`edit` now reach **all 7 item types** (Login, Secure Note, Identity, Card, Key, TOTP, Document); org attachments are collection-scoped on disk and grant-enforced at the hook. The C↔D path contract held in the merge — Dev-C's `save_attachment` emitter (`attachments/{slug}/{item}/{att}.enc`) exactly matches Dev-D's `classify_path` authorization. **Deploy note:** the pre-receive hook must be rebuilt on the server for attachment writes to be grant-scoped in production.
|
||||||
|
|
||||||
|
**Still deferred — forward plan in `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`:** extension org **read** (Dev-D) and **write** (Plan B-2) — the extension has no org concept yet; org phase-2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). That parity gap analysis is the authoritative forward plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension gaps: favorites UI, group/tag/filter editing, attachment-remove router wire, per-item purge).
|
||||||
|
|
||||||
|
### 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 `⤴` 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`)
|
||||||
|
|
||||||
|
### Enterprise org vault — core + server hook + CLI (merged 2026-06-20, `7392795`)
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md`; plan: `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md`
|
||||||
|
|
||||||
|
**relicario-core org module** (`crates/relicario-core/src/org.rs`): `OrgId`, `MemberId`, `OrgRole` (Owner/Admin/Member), `OrgMember`, `OrgMembers`/`OrgCollections`/`OrgMeta`/`OrgManifest`/`OrgManifestEntry` (all `schema_version: 1`); `generate_org_key`; ECIES X25519 key wrap/unwrap (`wrap_org_key` / `unwrap_org_key`) — ed25519→X25519 conversion via `SHA-512(seed)[..32]` + RFC 7748 clamp, ephemeral DH, `SHA-256(dh_shared || ephemeral_pk || recipient_pk)` wrap key, inner cipher delegated to `crate::crypto::encrypt` (XChaCha20-Poly1305, no Argon2id in org path); `OrgManifest::filter_for_member` for collection-scoped manifest views. Vault wrappers: `encrypt_org_manifest` / `decrypt_org_manifest` in `vault.rs`. 5 acceptance tests in `crates/relicario-core/tests/org.rs` incl. wrap/unwrap round-trip, revoke-after-rotation, manifest filter, and an RFC 8032 ed25519→X25519 known-answer vector.
|
||||||
|
|
||||||
|
**relicario-server org hook** (`crates/relicario-server/src/{lib.rs,main.rs}`): pure `classify_path` / `extract_schema_version` in new `lib.rs` target; `verify_org_commit` — commit-signature verification against `members.json` ed25519 keys, path-scoped authorization (protected JSON → owner/admin only; `items/<slug>/…` → slug in signer's grants), `enforce_owner_only_elevation` (parent-role check; guards against privilege self-escalation), `enforce_schema_monotonicity` (schema_version must not decrease; merge commits rejected; genesis allowed); `generate-org-hook` subcommand emits a wrapper script. New `[lib]` target added to `relicario-server` crate.
|
||||||
|
|
||||||
|
**relicario-cli — all 19 `relicario org` subcommands** (`crates/relicario-cli/src/{org_session.rs,commands/org.rs,device.rs}`): `org_session.rs` provides `UnlockedOrgVault` (org key in `Zeroizing`), collection-scoped `item_path`, fingerprint-based member match, `atomic_write`, `org_git_run` (signed commits — does NOT suppress `commit.gpgsign`).
|
||||||
|
|
||||||
|
Admin/lifecycle commands: `init` (structure + wrap + `configure_git_signing` + signed bootstrap commit), `add-member` / `remove-member` / `set-role` (owner-only escalation guard), `create-collection` / `grant` / `revoke`, `rotate-key` (fresh key + re-wrap all members + re-encrypt every `items/<slug>/<id>.enc` blob + manifest, concurrent-rotation abort, `Relicario-Action: key-rotate`), `transfer-ownership`, `delete-org`, `status`, `audit` (verified-signer attribution + TAMPERED flag).
|
||||||
|
|
||||||
|
Item CRUD commands (B9–B14): `org add` (`OrgAddKind`: Login/SecureNote/Identity; card/key/document/totp deferred — see below), `org get <query> [--show]`, `org list [--trashed]`, `org edit <query> [--title/--username/…]`, `org rm`, `org restore`, `org purge`. All ops are collection-scoped + grant-enforced; audit trail emits `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge`.
|
||||||
|
|
||||||
|
**A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed.
|
||||||
|
|
||||||
|
**Tracked follow-ups:**
|
||||||
|
- `org add` / `org edit` parity for Card, Key, Document, Totp — ✅ **SHIPPED v0.8.1** (`4c0a289`; all 7 item types now supported)
|
||||||
|
- Extension org-vault switch + read parity (Dev-D) — still deferred; forward plan in the parity gap analysis
|
||||||
|
- Extension org write operations — still deferred (Plan B-2)
|
||||||
|
- Phase 2: SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation), HTTP management plane
|
||||||
|
|
||||||
|
**Known limitations (by design in phase 1):** shared org master key — reads are not cryptographically scoped per collection (hook scopes writes; client filters manifest); no read audit (git records writes only); `delete-org` is a local tombstone only (hook rejects protected-file deletion on push).
|
||||||
|
|
||||||
|
### Extension restructure — Plan C Phases 3, 4, 6 (merged 2026-05-31 → 06-01, v0.7.0)
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
|
||||||
|
Plan: `docs/superpowers/plans/2026-05-30-extension-restructure.md`
|
||||||
|
|
||||||
|
Three parallel worktree streams under PM coordination (relay-bus), completing the restructure begun with Phases 1/2/5:
|
||||||
|
|
||||||
|
- **Phase 3 — setup wizard SW migration + step registry** (Dev-A, merge `9df2fee`). `create_vault` / `attach_vault` SW handlers own the full vault-creation/attach flow (embed/unlock, encrypt+push, register_device+addDevice, persist config+image, `session.setCurrent`; failure path locks+frees the handle). `setup.ts` collapses 1230→58 LOC (UI-only shell, no `relicario-wasm` import); step registry + state + `clearWizardState` + `finishSetup` extracted to new `setup/setup-steps.ts`. `clearWizardState` bound to `beforeunload` + `goto('mode')`. Copy-vault-JSON escape hatch preserved.
|
||||||
|
- **Phase 4 — vault.ts split + vault_locked lift** (Dev-B, merge `3b8368d`). `vault.ts` 1037→194 LOC. Five named modules (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`) plus two support modules (`vault-context` — the VaultController contract; `vault-router` — hash routing + pane dispatch, to hold vault.ts ≤250). `vault_locked` RPC intercept lifted into `shared/state.ts`'s `sendMessage` wrapper. 80ms debounced sidebar search (`SEARCH_DEBOUNCE_MS`); `ensureDrawerClosedForRoute`; `#vault-status-slot` footer staged for Phase 6.
|
||||||
|
- **Phase 6 — get_vault_status + sidebar status indicator** (Dev-C, merge `397cc78`). `get_vault_status` SW handler returns cached `{ahead, behind, lastSyncAt, pendingItems}` with no network call; `vault-status.ts` renders the sidebar-footer indicator (`renderStatusIndicator` into `#vault-status-slot`, refreshed on mount + manual `↻` button, no timer polling). Closes the last `relicario status` CLI/extension parity gap. Also nulls `state.gitHost` on the explicit `lock` handler (symmetric with session-expiry) so the indicator can't show a stale `lastSyncAt`.
|
||||||
|
|
||||||
|
Final merged-tree validation: **423/423 vitest** (62 files), `build:all` clean (only the pre-existing 4MB WASM size warning). Task 7.1 done-criteria sweep: all green.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review specs:
|
||||||
|
|
||||||
|
- **CLI restructure** (`2026-05-04-cli-restructure-design.md`) — *already shipped* as Plan B Cycles 1+2 (`b9bd152`, `3dd1e1b`, `3759f6a`, `e69b347`); the last gap (read-side `refresh_groups_cache` callers in list/get) closed in `d717f0d`. Done-criteria all met.
|
||||||
|
- **Security polish** (`2026-05-04-security-polish-design.md`) — *already shipped* as Stream A Cycle 1 (`89090a8`) plus follow-ups (`0c9387f` start.sh fourth window, `229e483` recovery_qr.rs docs). All four phases done.
|
||||||
|
- **Extension restructure** (`2026-05-04-extension-restructure-design.md`, plan `docs/superpowers/plans/2026-05-30-extension-restructure.md`) — ✅ **COMPLETE** (all six phases merged; see the dated landing section above). Phases 1/2/5 merged 2026-05-30; Phases 3/4/6 merged 2026-05-31 → 06-01. Final tree: 423/423 vitest, build:all clean. v0.7.0 versions bumped; tag pending.
|
||||||
|
|
||||||
|
**Enterprise org vault** — ✅ **COMPLETE (backend)** — all 19 CLI subcommands + core + server hook merged `7392795` 2026-06-20. Deferred follow-ups tracked in the landing section above.
|
||||||
|
|
||||||
|
Pending follow-ups (in rough priority order; **forward plan:** `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`):
|
||||||
|
- **Extension org parity — read** (Dev-D): org context switch + collection-filtered browse in the popup/vault tab
|
||||||
|
- **Extension org parity — write** (Plan B-2): `org add`/`edit`/`rm` from the extension — blocked behind extension org-read landing (and now unblocked on the CLI side, which reached all-7-type org write in v0.8.1)
|
||||||
|
- **Personal-side extension gaps** (from the parity analysis): favorites UI, group/tag editing on all type forms, popup type/tag filters, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching
|
||||||
|
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||||
|
|
||||||
|
Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (the `v0.8.1` CHANGELOG entry + version bump are owned by the PM in this lift).
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Architecture: relicario-cli
|
# 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
|
## What this crate is for
|
||||||
|
|
||||||
The `relicario` binary is the platform layer for `relicario-core`: it adds
|
The `relicario` binary is the platform layer for `relicario-core`: it adds
|
||||||
@@ -16,22 +18,62 @@ locally, and lets recovery debugging happen with familiar tooling.
|
|||||||
|
|
||||||
## Module map
|
## Module map
|
||||||
|
|
||||||
The crate is three files of source and a `tests/` directory. Each source file
|
`src/main.rs` is now a thin clap-surface + dispatcher; per-command logic lives
|
||||||
has one job.
|
under `src/commands/`. Each source file has one job.
|
||||||
|
|
||||||
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
|
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
|
||||||
handler. Internal structure: a top-level `Cli` / `Commands` enum
|
Owns the top-level `Cli` / `Commands` enum and every subcommand enum
|
||||||
(`main.rs:13-275`), a flat dispatcher `match` in `main()`
|
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
|
||||||
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
|
`DeviceAction`, `RecoveryQrCmd`), plus the org clap surface `OrgCommands`
|
||||||
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
|
(`main.rs:448`) and `OrgAddKind` (`main.rs:556`) — the latter's Card / Key /
|
||||||
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
|
Document / Totp variants carry `--collection` and the `--*-stdin` secret flags.
|
||||||
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
|
`main()` is a single `match` that
|
||||||
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
|
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
|
||||||
can be tested through the same integration paths. Owns all clap argument
|
three test-only env-var hooks (`test_passphrase_override`,
|
||||||
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
|
`test_item_secret_override`, `test_backup_passphrase_override`) — each is
|
||||||
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
|
stripped from release builds via `#[cfg(debug_assertions)]`.
|
||||||
`commit_paths` helper that is the single chokepoint for git commits during
|
|
||||||
vault mutations.
|
- **`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` resolve their non-secret
|
||||||
|
fields then delegate to the shared `item_build` module's per-`ItemCore`
|
||||||
|
`build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
|
||||||
|
reads top-to-bottom and can be tested through the same integration paths.
|
||||||
|
|
||||||
|
- **`src/commands/item_build.rs`** — shared per-type item construction and
|
||||||
|
interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
|
||||||
|
(`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
|
||||||
|
resolution (`resolve_secret_line` — reads one line from stdin or falls back
|
||||||
|
to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
|
||||||
|
EOF, printing an optional hint in the interactive case); type parsers
|
||||||
|
(`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
|
||||||
|
(`build_login`, `build_secure_note`, `build_identity`, `build_card`,
|
||||||
|
`build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
|
||||||
|
(`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
|
||||||
|
`edit_identity`, `edit_document_message`); and `push_history`.
|
||||||
|
|
||||||
|
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
|
||||||
|
`prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
|
||||||
|
flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
|
||||||
|
`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
|
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
|
||||||
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
|
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
|
||||||
@@ -45,6 +87,73 @@ has one job.
|
|||||||
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
|
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
|
||||||
(`session.rs:125`) that integration tests use to bypass the TTY.
|
(`session.rs:125`) that integration tests use to bypass the TTY.
|
||||||
|
|
||||||
|
- **`src/org_session.rs`** — `UnlockedOrgVault`, the org-vault analogue of
|
||||||
|
`session.rs`. Holds the org master key in `Zeroizing<[u8; 32]>` for one CLI
|
||||||
|
invocation, recovered by unwrapping `keys/<member-id>.enc` with the device
|
||||||
|
ed25519 seed. `open_org_vault` calls `crate::device::current_device_seed()`
|
||||||
|
directly (`device.rs`) — a duplicate private fn that previously existed in
|
||||||
|
`org_session.rs` was removed during the A5 sweep (implementations were
|
||||||
|
identical). Owns the **collection-scoped** `item_path`
|
||||||
|
(`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive
|
||||||
|
hook authorizes against, never decrypting), fingerprint-based member matching
|
||||||
|
(`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment
|
||||||
|
differences), `atomic_write`, and `org_git_run`. As of v0.8.1 it also owns
|
||||||
|
**collection-scoped attachment storage** — `attachment_path` /
|
||||||
|
`save_attachment` / `load_attachment` / `remove_item_attachments`
|
||||||
|
(`org_session.rs:125-157`) at layout
|
||||||
|
`attachments/<collection-slug>/<item-id>/<att-id>.enc` (the same leading slug
|
||||||
|
the pre-receive hook authorizes against as for `item_path`), capped
|
||||||
|
per-attachment by `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` (10 MiB,
|
||||||
|
`org_session.rs:20`). Note `org_git_run` runs
|
||||||
|
**bare git** — unlike `helpers::git_run` it does NOT inject
|
||||||
|
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
|
||||||
|
every commit's signature); signing config is established by
|
||||||
|
`configure_git_signing` during `org init`.
|
||||||
|
|
||||||
|
- **`src/commands/org.rs`** — the `relicario org` subcommand surface. Full
|
||||||
|
19-subcommand surface is merged and wired via `Commands::Org` in `main.rs`.
|
||||||
|
|
||||||
|
*Admin / lifecycle (12):* `init` (structure + wrap + `configure_git_signing` +
|
||||||
|
signed bootstrap commit), `add-member` / `remove-member` / `set-role`
|
||||||
|
(owner-only escalation guard), `create-collection` / `grant` / `revoke`,
|
||||||
|
`rotate-key` (`run_rotate_key`, `commands/org.rs:332` — fresh key, re-wrap for
|
||||||
|
all members, re-encrypt every item blob + manifest under the new key,
|
||||||
|
concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` /
|
||||||
|
`audit` (verified-signer attribution + `TAMPERED` flag).
|
||||||
|
|
||||||
|
*Item CRUD (7):* full item-type parity with the personal vault (v0.8.1).
|
||||||
|
`org add` creates **all seven types** (Login / SecureNote / Identity / Card /
|
||||||
|
Key / Document / Totp) via `OrgAddKind` (`commands/org.rs:751`); each arm
|
||||||
|
delegates to the shared `item_build::build_*` builders through `build_org_item`
|
||||||
|
(`commands/org.rs:799`), and `run_add` (`commands/org.rs:823`) sets tags
|
||||||
|
post-build. Document is special-cased in `run_add` (`commands/org.rs:839`): its
|
||||||
|
builder also yields an `EncryptedAttachment` that is written via
|
||||||
|
`save_attachment` and git-staged before the signed commit. Single-line secrets
|
||||||
|
(card number/CVV/PIN, TOTP secret, login password) accept a `--*-stdin` flag;
|
||||||
|
multiline secrets (Key material, SecureNote body) read stdin to EOF — the same
|
||||||
|
`resolve_secret_line` / `resolve_secret_multiline` convention as personal `add`
|
||||||
|
(`commands/item_build.rs`).
|
||||||
|
|
||||||
|
`org edit <query>` (`run_edit`, `commands/org.rs:1004`) is **interactive
|
||||||
|
per-type** as of v0.8.1 (it was flag-driven before): it prompts Title, then
|
||||||
|
dispatches on `&mut item.core` to the shared `item_build::edit_*` helpers
|
||||||
|
("blank keeps current", field-history capture via `push_history`), mirroring
|
||||||
|
personal `cmd_edit`. `--totp-qr` sets a Login TOTP from a QR image; `--file`
|
||||||
|
replaces a Document's primary attachment (`commands/org.rs:1039`, rejected for
|
||||||
|
non-Document items at `commands/org.rs:1018`). The edit commit carries
|
||||||
|
`Relicario-Action: item-update`.
|
||||||
|
|
||||||
|
`org get <query> [--show]` masks every secret unless `--show`; `org list
|
||||||
|
[--trashed]` filters by the caller's collection grants; `org rm` soft-deletes,
|
||||||
|
`org restore` undoes, `org purge` (`run_purge`, `commands/org.rs:1164`)
|
||||||
|
permanently removes the encrypted blob **and** the item's attachment directory
|
||||||
|
(`remove_item_attachments`, `commands/org.rs:1173`). All item ops are
|
||||||
|
collection-scoped and grant-enforced (`filter_for_member` over the manifest +
|
||||||
|
`ensure_grant` before any load/mutate). The audit trail emits `item-create` /
|
||||||
|
`item-update` / `item-delete` / `item-restore` / `item-purge`.
|
||||||
|
|
||||||
|
Deferred: extension org reads and writes (Plan B-2 / phase 2).
|
||||||
|
|
||||||
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
||||||
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
||||||
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
|
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
|
||||||
@@ -100,7 +209,7 @@ in code; cite the line if you change it.
|
|||||||
works without any setup.
|
works without any setup.
|
||||||
|
|
||||||
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
||||||
directly; `Item::new` (called inside every `build_*_item`) does it via
|
directly; `Item::new` (called inside every `item_build::build_*`) does it via
|
||||||
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
||||||
|
|
||||||
- **Manifest is always saved last.** Within a single command, the order is:
|
- **Manifest is always saved last.** Within a single command, the order is:
|
||||||
@@ -170,15 +279,23 @@ in code; cite the line if you change it.
|
|||||||
### Item add (`cmd_add`, `main.rs:419-456`)
|
### Item add (`cmd_add`, `main.rs:419-456`)
|
||||||
|
|
||||||
1. Unlock the vault and load the manifest.
|
1. Unlock the vault and load the manifest.
|
||||||
2. Match on the `AddKind` variant and dispatch to the matching
|
2. Match on the `AddKind` variant: resolve `title` and non-secret fields
|
||||||
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
|
(username, URL, holder, expiry, etc.) via `prompt_or_flag` /
|
||||||
builders; only `build_document_item` takes `&UnlockedVault` because it
|
`prompt_or_flag_optional`, then delegate to the matching `build_*` builder
|
||||||
needs `attachment_caps` and writes the encrypted blob alongside the item.
|
in `commands/item_build.rs`. Seven variants → seven builders; only
|
||||||
3. The builder returns a fully-populated `Item` (with title, group, tags,
|
`build_document` takes `&UnlockedVault` because it needs `attachment_caps`
|
||||||
|
and writes the encrypted blob alongside the item.
|
||||||
|
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
|
||||||
|
accept a `--*-stdin` flag that reads one line from stdin instead of
|
||||||
|
prompting; multiline secrets (SecureNote body, Key material) always read
|
||||||
|
stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
|
||||||
|
Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
|
||||||
|
`resolve_secret_line` / `resolve_secret_multiline`.
|
||||||
|
4. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||||
favorite-flag, primary attachment if any).
|
favorite-flag, primary attachment if any).
|
||||||
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
5. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||||
`vault.save_manifest(&manifest)`.
|
`vault.save_manifest(&manifest)`.
|
||||||
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
6. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||||
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
||||||
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
||||||
|
|
||||||
@@ -306,13 +423,65 @@ rewrite `devices.json`, commit `device: revoke <name>`. Note that device
|
|||||||
keys are kept entirely separate from the KDF (passphrase × image stays
|
keys are kept entirely separate from the KDF (passphrase × image stays
|
||||||
unchanged across device add/revoke), as per the design spec.
|
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
|
Two subcommands, both keyed by a *backup* passphrase that is independent of
|
||||||
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
|
the vault master passphrase.
|
||||||
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
|
- **`backup export <out> [--include-image] [--image PATH] [--no-history]`** —
|
||||||
until that work begins.
|
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 0–4 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
|
## Cross-cutting concerns
|
||||||
|
|
||||||
@@ -459,11 +628,12 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
|
|||||||
instead. Non-primary attachments on a Document (e.g., a scanned
|
instead. Non-primary attachments on a Document (e.g., a scanned
|
||||||
contract with an addendum) detach normally.
|
contract with an addendum) detach normally.
|
||||||
|
|
||||||
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
|
- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
|
||||||
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
|
`3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
|
||||||
carried 217-line `match` arms. The split-out functions are easier to
|
personal and org surfaces share one set). Before the extraction, `cmd_add`
|
||||||
read, easier to test individually (the existing integration tests still
|
and `cmd_edit` carried 217-line `match` arms. The split-out functions are
|
||||||
drive them through the same paths), and easier to grow when a new
|
easier to read, easier to test individually (the existing integration tests
|
||||||
|
still drive them through the same paths), and easier to grow when a new
|
||||||
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
||||||
|
|
||||||
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
||||||
@@ -537,3 +707,7 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
|
|||||||
is why every `cmd_*` that takes a `query: String` (get, edit,
|
is why every `cmd_*` that takes a `query: String` (get, edit,
|
||||||
history, rm, restore, purge, attach, attachments, extract, detach)
|
history, rm, restore, purge, attach, attachments, extract, detach)
|
||||||
works the same way.
|
works the same way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next:** [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.5.0"
|
version = "0.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "CLI for relicario password manager"
|
description = "CLI for relicario password manager"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "relicario"
|
name = "relicario"
|
||||||
@@ -29,9 +30,12 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
|
|||||||
rqrr = "0.7"
|
rqrr = "0.7"
|
||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
qrcode = { version = "0.14", features = ["svg"] }
|
qrcode = { version = "0.14", features = ["svg"] }
|
||||||
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
|
regex = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
tempfile = "3"
|
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
|||||||
92
crates/relicario-cli/src/commands/add.rs
Normal file
92
crates/relicario-cli/src/commands/add.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! `relicario add <kind>` — create a new item of the given type.
|
||||||
|
//!
|
||||||
|
//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the
|
||||||
|
//! shared builders in `commands/item_build.rs`. Group / tags / favorite are
|
||||||
|
//! set AFTER the build so the builders stay portable to the org vault.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::AddKind;
|
||||||
|
use crate::commands::item_build as ib;
|
||||||
|
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
|
||||||
|
|
||||||
|
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let item = match kind {
|
||||||
|
AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
||||||
|
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?;
|
||||||
|
item.group = group; item.tags = tags; item.favorite = favorite;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::SecureNote { title, body_stdin, group, tags } => {
|
||||||
|
// Per the v0.8.1 spec's unified secret model, a note body is a
|
||||||
|
// multiline secret that always reads stdin to EOF. `body_stdin=false`
|
||||||
|
// means "print the Ctrl-D hint" (interactive default); `true` suppresses
|
||||||
|
// the hint for non-interactive use.
|
||||||
|
// Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`.
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_secure_note(title, None, body_stdin)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_identity(title, full_name, email, phone, date_of_birth)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Key { title, label, algorithm, material_stdin, group, tags } => {
|
||||||
|
// public_key is None for the personal vault: the legacy `prompt_optional`
|
||||||
|
// for it was unreachable (stdin already at EOF after the key-material read).
|
||||||
|
// Org `add key` (Dev-B) supplies it via --public-key.
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Document { title, file, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let caps = vault.load_settings()?.attachment_caps;
|
||||||
|
let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||||
|
std::fs::create_dir_all(&att_dir)?;
|
||||||
|
std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let mut paths: Vec<String> = vec![
|
||||||
|
format!("items/{}.enc", item.id.as_str()),
|
||||||
|
"manifest.enc".into(),
|
||||||
|
];
|
||||||
|
for att in &item.attachments {
|
||||||
|
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
||||||
|
}
|
||||||
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
|
super::commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
|
||||||
|
|
||||||
|
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
175
crates/relicario-cli/src/commands/attach.rs
Normal file
175
crates/relicario-cli/src/commands/attach.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::parse::guess_mime;
|
||||||
|
|
||||||
|
pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
let settings = vault.load_settings()?;
|
||||||
|
let caps = settings.attachment_caps;
|
||||||
|
|
||||||
|
if item.attachments.len() as u32 >= caps.per_item_max_count {
|
||||||
|
anyhow::bail!("item already has {} attachments (max {})",
|
||||||
|
item.attachments.len(), caps.per_item_max_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = fs::read(&file)
|
||||||
|
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||||
|
|
||||||
|
// Check per-vault total attachment bytes cap (audit I3).
|
||||||
|
let current_total: u64 = manifest.items.values()
|
||||||
|
.flat_map(|e| &e.attachment_summaries)
|
||||||
|
.map(|s| s.size)
|
||||||
|
.sum();
|
||||||
|
let new_size = bytes.len() as u64;
|
||||||
|
let hard_cap = caps.per_vault_hard_cap_bytes;
|
||||||
|
let soft_cap = caps.per_vault_soft_cap_bytes;
|
||||||
|
if current_total + new_size > hard_cap {
|
||||||
|
anyhow::bail!(
|
||||||
|
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
|
||||||
|
current_total, new_size, hard_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if current_total + new_size > soft_cap {
|
||||||
|
eprintln!(
|
||||||
|
"warning: vault attachments will exceed soft cap ({} bytes)",
|
||||||
|
soft_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||||
|
|
||||||
|
let filename = file.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let mime_type = guess_mime(&filename);
|
||||||
|
let aref = AttachmentRef {
|
||||||
|
id: enc.id.clone(),
|
||||||
|
filename,
|
||||||
|
mime_type,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
created: now_unix(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||||
|
fs::create_dir_all(&att_dir)?;
|
||||||
|
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||||
|
|
||||||
|
item.attachments.push(aref);
|
||||||
|
item.modified = now_unix();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let paths = [
|
||||||
|
format!("items/{}.enc", item.id.as_str()),
|
||||||
|
"manifest.enc".into(),
|
||||||
|
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
|
||||||
|
];
|
||||||
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
|
super::commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||||
|
crate::helpers::sanitize_for_commit(&file.display().to_string()),
|
||||||
|
crate::helpers::sanitize_for_commit(&item.title),
|
||||||
|
item.id.as_str()), &path_refs)?;
|
||||||
|
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_attachments(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||||
|
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||||
|
for a in &item.attachments {
|
||||||
|
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::decrypt_attachment;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||||
|
let path = vault.root().join("attachments").join(item.id.as_str())
|
||||||
|
.join(format!("{}.enc", aid));
|
||||||
|
let bytes = fs::read(&path)
|
||||||
|
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
|
let plaintext = decrypt_attachment(&bytes, vault.key())?;
|
||||||
|
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
|
||||||
|
fs::write(&out_path, plaintext.as_slice())
|
||||||
|
.with_context(|| format!("failed to write {}", out_path.display()))?;
|
||||||
|
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
|
||||||
|
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||||
|
|
||||||
|
// Document items keep their primary blob in the core; refuse to orphan it.
|
||||||
|
if let ItemCore::Document(d) = &item.core {
|
||||||
|
if d.primary_attachment.as_str() == aid {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot detach the primary attachment of a Document item; \
|
||||||
|
use `purge {}` to delete the whole item",
|
||||||
|
item.title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = item.attachments.remove(pos);
|
||||||
|
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
||||||
|
.join(format!("{}.enc", removed.id.as_str()));
|
||||||
|
if blob_path.exists() {
|
||||||
|
fs::remove_file(&blob_path)
|
||||||
|
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.modified = now_unix();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||||
|
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||||
|
super::commit_paths(
|
||||||
|
&vault,
|
||||||
|
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&item_path, "manifest.enc", &blob_relpath],
|
||||||
|
)?;
|
||||||
|
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
303
crates/relicario-cli/src/commands/backup.rs
Normal file
303
crates/relicario-cli/src/commands/backup.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
//! `relicario backup export` / `relicario backup restore` — pack/unpack the
|
||||||
|
//! encrypted `.relbak` envelope.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::BackupAction;
|
||||||
|
|
||||||
|
pub fn cmd_backup(action: BackupAction) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
BackupAction::Export { out, include_image, image, no_history } => {
|
||||||
|
cmd_backup_export(out, include_image, image, no_history)
|
||||||
|
}
|
||||||
|
BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cmd_backup_export(
|
||||||
|
out: PathBuf,
|
||||||
|
include_image: bool,
|
||||||
|
image: Option<PathBuf>,
|
||||||
|
no_history: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::{backup, validate_passphrase_strength};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
|
||||||
|
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
||||||
|
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
|
};
|
||||||
|
let confirm = if crate::test_backup_passphrase_override().is_some() {
|
||||||
|
passphrase.clone()
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
|
};
|
||||||
|
if passphrase.as_str() != confirm.as_str() {
|
||||||
|
anyhow::bail!("passphrases do not match");
|
||||||
|
}
|
||||||
|
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||||
|
anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read everything from disk that the envelope needs.
|
||||||
|
let salt = fs::read(root.join(".relicario").join("salt"))
|
||||||
|
.with_context(|| "failed to read .relicario/salt")?;
|
||||||
|
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
|
.with_context(|| "failed to read .relicario/params.json")?;
|
||||||
|
// devices.json was removed in the B1 security audit fix; fall back to
|
||||||
|
// an empty array so backups of post-B1 vaults still pack cleanly.
|
||||||
|
// Task 12 will remove the devices field from the backup format entirely.
|
||||||
|
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
||||||
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
|
let manifest_enc = fs::read(root.join("manifest.enc"))
|
||||||
|
.with_context(|| "failed to read manifest.enc")?;
|
||||||
|
let settings_enc = fs::read(root.join("settings.enc"))
|
||||||
|
.with_context(|| "failed to read settings.enc")?;
|
||||||
|
|
||||||
|
// Items.
|
||||||
|
let mut item_files = Vec::new();
|
||||||
|
let items_dir = root.join("items");
|
||||||
|
if items_dir.is_dir() {
|
||||||
|
for entry in fs::read_dir(&items_dir)? {
|
||||||
|
let p = entry?.path();
|
||||||
|
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||||
|
let id = p.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
|
||||||
|
.to_string();
|
||||||
|
let bytes = fs::read(&p)?;
|
||||||
|
item_files.push((id, bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments. Layout: attachments/<item_id>/<aid>.enc
|
||||||
|
let mut attach_files = Vec::new();
|
||||||
|
let attach_dir = root.join("attachments");
|
||||||
|
if attach_dir.is_dir() {
|
||||||
|
for entry in fs::read_dir(&attach_dir)? {
|
||||||
|
let item_dir = entry?.path();
|
||||||
|
if !item_dir.is_dir() { continue; }
|
||||||
|
let item_id = item_dir.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
|
||||||
|
.to_string();
|
||||||
|
for sub in fs::read_dir(&item_dir)? {
|
||||||
|
let p = sub?.path();
|
||||||
|
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||||
|
let aid = p.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
|
||||||
|
.to_string();
|
||||||
|
let bytes = fs::read(&p)?;
|
||||||
|
attach_files.push((item_id.clone(), aid, bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional reference image.
|
||||||
|
let image_bytes = if include_image {
|
||||||
|
let path = match image {
|
||||||
|
Some(p) => p,
|
||||||
|
None => crate::session::get_image_path()?,
|
||||||
|
};
|
||||||
|
Some(fs::read(&path)
|
||||||
|
.with_context(|| format!("failed to read reference image {}", path.display()))?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional .git/ tar.
|
||||||
|
let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };
|
||||||
|
|
||||||
|
let items_refs: Vec<backup::BackupItem> = item_files.iter()
|
||||||
|
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
|
||||||
|
.collect();
|
||||||
|
let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
|
||||||
|
.map(|(iid, aid, bytes)| backup::BackupAttachment {
|
||||||
|
item_id: iid.clone(),
|
||||||
|
attachment_id: aid.clone(),
|
||||||
|
ciphertext: bytes,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let input = backup::BackupInput {
|
||||||
|
salt: &salt,
|
||||||
|
params_json: ¶ms_json,
|
||||||
|
devices_json: &devices_json,
|
||||||
|
manifest_enc: &manifest_enc,
|
||||||
|
settings_enc: &settings_enc,
|
||||||
|
items: items_refs,
|
||||||
|
attachments: attach_refs,
|
||||||
|
reference_jpg: image_bytes.as_deref(),
|
||||||
|
git_archive: git_archive.as_deref(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = backup::pack_backup(input, &passphrase)?;
|
||||||
|
|
||||||
|
// atomic_write via the existing pattern: write `.tmp`, rename.
|
||||||
|
let tmp = {
|
||||||
|
let mut t = out.as_os_str().to_owned();
|
||||||
|
t.push(".tmp");
|
||||||
|
PathBuf::from(t)
|
||||||
|
};
|
||||||
|
fs::write(&tmp, &bytes)
|
||||||
|
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, &out)
|
||||||
|
.with_context(|| format!("failed to rename {}", out.display()))?;
|
||||||
|
|
||||||
|
// Marker file for `cmd_status`. Format: ISO-8601 UTC line.
|
||||||
|
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||||
|
fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;
|
||||||
|
|
||||||
|
let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
|
||||||
|
eprintln!(
|
||||||
|
"Wrote {} ({:.2} MiB). Delete after restore is verified.",
|
||||||
|
out.display(), mib
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
|
||||||
|
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut buf);
|
||||||
|
builder.append_dir_all(".", dir)
|
||||||
|
.with_context(|| format!("failed to tar {}", dir.display()))?;
|
||||||
|
builder.finish().with_context(|| "failed to finalize git tar")?;
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::backup;
|
||||||
|
use relicario_core::{ItemId, AttachmentId};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let target = if target.is_absolute() {
|
||||||
|
target
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()?.join(&target)
|
||||||
|
};
|
||||||
|
|
||||||
|
if target.join(".relicario").exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&target)
|
||||||
|
.with_context(|| format!("failed to create target {}", target.display()))?;
|
||||||
|
|
||||||
|
// Read input file.
|
||||||
|
let bytes = fs::read(&input)
|
||||||
|
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
||||||
|
|
||||||
|
// Backup passphrase prompt.
|
||||||
|
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
|
};
|
||||||
|
|
||||||
|
let unpacked = backup::unpack_backup(&bytes, &passphrase)
|
||||||
|
.map_err(|e| match e {
|
||||||
|
relicario_core::RelicarioError::Decrypt =>
|
||||||
|
anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
|
||||||
|
other => anyhow::anyhow!(other),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Write vault layout.
|
||||||
|
let relicario_dir = target.join(".relicario");
|
||||||
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
|
fs::create_dir_all(target.join("items"))?;
|
||||||
|
fs::create_dir_all(target.join("attachments"))?;
|
||||||
|
|
||||||
|
fs::write(relicario_dir.join("salt"), unpacked.salt)?;
|
||||||
|
fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
|
||||||
|
fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
|
||||||
|
fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
|
||||||
|
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
||||||
|
|
||||||
|
for item in &unpacked.items {
|
||||||
|
let item_id = ItemId(item.id.clone());
|
||||||
|
if !item_id.is_valid() {
|
||||||
|
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
|
||||||
|
}
|
||||||
|
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
||||||
|
}
|
||||||
|
for a in &unpacked.attachments {
|
||||||
|
let item_id = ItemId(a.item_id.clone());
|
||||||
|
let att_id = AttachmentId(a.attachment_id.clone());
|
||||||
|
if !item_id.is_valid() || !att_id.is_valid() {
|
||||||
|
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
|
||||||
|
}
|
||||||
|
let dir = target.join("attachments").join(&a.item_id);
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference image (if present).
|
||||||
|
if let Some(jpg) = &unpacked.reference_jpg {
|
||||||
|
let path = target.join("reference.jpg");
|
||||||
|
fs::write(&path, jpg)
|
||||||
|
.with_context(|| format!("failed to write reference image {}", path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .git/ history.
|
||||||
|
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||||
|
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||||
|
let cap = std::cmp::min(
|
||||||
|
(tar_bytes.len() as u64).saturating_mul(100),
|
||||||
|
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
||||||
|
);
|
||||||
|
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
||||||
|
.with_context(|| "failed to safely unpack .git/ archive")?;
|
||||||
|
let git_dir = target.join(".git");
|
||||||
|
for (rel_path, body) in entries {
|
||||||
|
let dest = git_dir.join(&rel_path);
|
||||||
|
// Paranoid OS-level check even after textual validation in core.
|
||||||
|
if !dest.starts_with(&git_dir) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
||||||
|
rel_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| {
|
||||||
|
format!("create parent {}", parent.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::write(&dest, &body).with_context(|| {
|
||||||
|
format!("write {}", dest.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No history bundled — start a fresh git repo.
|
||||||
|
crate::helpers::git_run(&target, &["init"], "backup restore: git init")?;
|
||||||
|
|
||||||
|
// .gitignore — exclude reference image if present.
|
||||||
|
if target.join("reference.jpg").exists() {
|
||||||
|
fs::write(target.join(".gitignore"), "reference.jpg\n")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
|
||||||
|
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||||
|
let msg = format!("restore from backup {now_iso}");
|
||||||
|
let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Restored vault to {}. Unlock with your passphrase + reference image.",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
255
crates/relicario-cli/src/commands/device.rs
Normal file
255
crates/relicario-cli/src/commands/device.rs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
//! `relicario device {add, revoke, list}` — device key management.
|
||||||
|
//!
|
||||||
|
//! Note: command bodies live here as `crate::commands::device`. Local key
|
||||||
|
//! storage and git-signing config live separately in `crate::device`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::DeviceAction;
|
||||||
|
|
||||||
|
/// Build a `GiteaClient` from flags or environment variables.
|
||||||
|
fn load_gitea_client(
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
owner: Option<String>,
|
||||||
|
repo: Option<String>,
|
||||||
|
) -> Result<crate::gitea::GiteaClient> {
|
||||||
|
let url = gitea_url
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
|
||||||
|
))?;
|
||||||
|
let token = gitea_token
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
|
||||||
|
))?;
|
||||||
|
let owner = owner
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
|
||||||
|
))?;
|
||||||
|
let repo = repo
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
|
||||||
|
))?;
|
||||||
|
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
|
||||||
|
|
||||||
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
let relicario_dir = root.join(".relicario");
|
||||||
|
let devices_path = relicario_dir.join("devices.json");
|
||||||
|
|
||||||
|
match action {
|
||||||
|
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
|
||||||
|
// Guard: don't overwrite an already-registered device name.
|
||||||
|
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if existing.iter().any(|d| d.name == name) {
|
||||||
|
anyhow::bail!("a device named '{}' is already registered", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Generating signing keypair...");
|
||||||
|
let (signing_priv, signing_pub) = generate_keypair()
|
||||||
|
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
|
||||||
|
|
||||||
|
eprintln!("Generating deploy keypair...");
|
||||||
|
let (deploy_priv, deploy_pub) = generate_keypair()
|
||||||
|
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
|
||||||
|
|
||||||
|
// Optionally register deploy key with Gitea.
|
||||||
|
let gitea_key_id: u64 = if no_gitea {
|
||||||
|
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
|
||||||
|
let key_title = format!("relicario-{}", name);
|
||||||
|
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
|
||||||
|
client.create_deploy_key(&key_title, &deploy_pub)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store keys locally with proper permissions.
|
||||||
|
crate::device::store_device_keys(
|
||||||
|
&name,
|
||||||
|
&signing_priv,
|
||||||
|
&signing_pub,
|
||||||
|
&deploy_priv,
|
||||||
|
&deploy_pub,
|
||||||
|
gitea_key_id,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Mark as current device.
|
||||||
|
crate::device::set_current_device(&name)?;
|
||||||
|
|
||||||
|
// Configure git signing + SSH deploy key in the vault repo.
|
||||||
|
crate::device::configure_git_signing(&root, &name)?;
|
||||||
|
|
||||||
|
// Update devices.json.
|
||||||
|
let current_name = name.clone();
|
||||||
|
let mut devices = existing;
|
||||||
|
devices.push(DeviceEntry {
|
||||||
|
name: name.clone(),
|
||||||
|
public_key: signing_pub.clone(),
|
||||||
|
added_at: relicario_core::now_unix(),
|
||||||
|
added_by: current_name,
|
||||||
|
});
|
||||||
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||||
|
|
||||||
|
// Commit the update.
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["add", ".relicario/devices.json"],
|
||||||
|
&format!("device register \"{name}\": git add .relicario/devices.json"),
|
||||||
|
)?;
|
||||||
|
let msg = format!("device: register {}", name);
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["commit", "-m", &msg],
|
||||||
|
&format!("device register \"{name}\": git commit"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Device '{}' registered.", name);
|
||||||
|
eprintln!("Signing public key:");
|
||||||
|
eprintln!(" {}", signing_pub);
|
||||||
|
if gitea_key_id != 0 {
|
||||||
|
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAction::Revoke { name } => {
|
||||||
|
// Guard: refuse to revoke the currently active device (would lock
|
||||||
|
// the user out). They must add another device first.
|
||||||
|
if let Some(current) = crate::device::current_device()? {
|
||||||
|
if current == name {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot revoke the current device '{}' — you would lose \
|
||||||
|
push access. Register another device first.",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load devices.json.
|
||||||
|
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let device = devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.name == name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Remove from devices.json.
|
||||||
|
devices.retain(|d| d.name != name);
|
||||||
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||||
|
|
||||||
|
// Append to revoked.json.
|
||||||
|
let revoked_path = relicario_dir.join("revoked.json");
|
||||||
|
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let revoked_by = crate::device::current_device()?
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
revoked.push(RevokedEntry {
|
||||||
|
name: name.clone(),
|
||||||
|
public_key: device.public_key.clone(),
|
||||||
|
revoked_at: relicario_core::now_unix(),
|
||||||
|
revoked_by,
|
||||||
|
});
|
||||||
|
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
|
||||||
|
|
||||||
|
// Delete deploy key from Gitea (best-effort — don't fail if it
|
||||||
|
// was already deleted or the config is missing).
|
||||||
|
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
|
||||||
|
if key_id != 0 {
|
||||||
|
// Build client from env vars only (no flags in revoke).
|
||||||
|
match load_gitea_client(None, None, None, None) {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(e) = client.delete_deploy_key(key_id) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to delete Gitea deploy key {}: {}",
|
||||||
|
key_id, e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("Deleted Gitea deploy key {}.", key_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"warning: Gitea env vars not set — deploy key {} \
|
||||||
|
not deleted from Gitea.",
|
||||||
|
key_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit devices.json + revoked.json (always both — revoked.json
|
||||||
|
// was just written above so it is guaranteed to exist).
|
||||||
|
let add_args = [
|
||||||
|
"add",
|
||||||
|
".relicario/devices.json",
|
||||||
|
".relicario/revoked.json",
|
||||||
|
];
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&add_args,
|
||||||
|
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
|
||||||
|
)?;
|
||||||
|
let msg = format!("device: revoke {}", name);
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["commit", "-m", &msg],
|
||||||
|
&format!("device revoke \"{name}\": git commit"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Device '{}' revoked.", name);
|
||||||
|
eprintln!("Revoked signing key: {}", device.public_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAction::List => {
|
||||||
|
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let current = crate::device::current_device()?.unwrap_or_default();
|
||||||
|
|
||||||
|
if devices.is_empty() {
|
||||||
|
println!("No registered devices.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
||||||
|
println!("{}", "-".repeat(72));
|
||||||
|
for d in &devices {
|
||||||
|
let marker = if d.name == current { " *" } else { "" };
|
||||||
|
let added = crate::helpers::iso8601(d.added_at);
|
||||||
|
// Show only the first 40 chars of the public key line for readability.
|
||||||
|
let key_prefix: String = d.public_key.chars().take(40).collect();
|
||||||
|
println!("{:<20} {:<20} {}{}",
|
||||||
|
d.name, added, key_prefix, marker);
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
println!("\n* = current device");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/relicario-cli/src/commands/edit.rs
Normal file
48
crates/relicario-cli/src/commands/edit.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! `relicario edit <query>` — interactive per-type field editing with history capture.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::prompt::{prompt_keep, prompt_keep_opt};
|
||||||
|
|
||||||
|
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
|
||||||
|
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||||
|
item.title, item.id.as_str());
|
||||||
|
|
||||||
|
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
||||||
|
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
|
||||||
|
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let history = &mut item.field_history;
|
||||||
|
match &mut item.core {
|
||||||
|
ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?,
|
||||||
|
ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
|
||||||
|
ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?,
|
||||||
|
ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?,
|
||||||
|
ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?,
|
||||||
|
ItemCore::Document(_) => crate::commands::item_build::edit_document_message(),
|
||||||
|
ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?,
|
||||||
|
}
|
||||||
|
|
||||||
|
item.modified = now_unix();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
68
crates/relicario-cli/src/commands/generate.rs
Normal file
68
crates/relicario-cli/src/commands/generate.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//! `relicario generate` — emit a fresh password or BIP39 passphrase.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_generate(
|
||||||
|
length: Option<u32>,
|
||||||
|
bip39: bool,
|
||||||
|
words: Option<u32>,
|
||||||
|
symbols: Option<String>,
|
||||||
|
separator: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::{
|
||||||
|
generate_passphrase, generate_password, Capitalization, CharClasses,
|
||||||
|
GeneratorRequest, SymbolCharset,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we're inside a vault, unlock and pull `generator_defaults`. Outside
|
||||||
|
// a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
|
||||||
|
let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
Some(vault.load_settings()?.generator_defaults)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
|
||||||
|
// vault default is in (Random when no vault).
|
||||||
|
let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));
|
||||||
|
|
||||||
|
let output = if use_bip39 {
|
||||||
|
let (def_words, def_sep, def_cap) = match &vault_defaults {
|
||||||
|
Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
|
||||||
|
(*word_count, separator.clone(), *capitalization)
|
||||||
|
}
|
||||||
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||||
|
};
|
||||||
|
generate_passphrase(&GeneratorRequest::Bip39 {
|
||||||
|
word_count: words.unwrap_or(def_words),
|
||||||
|
separator: separator.unwrap_or(def_sep),
|
||||||
|
capitalization: def_cap,
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
let (def_length, def_classes, def_charset) = match &vault_defaults {
|
||||||
|
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
|
||||||
|
(*length, *classes, symbol_charset.clone())
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
20,
|
||||||
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
SymbolCharset::SafeOnly,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let symbol_charset = match symbols.as_deref() {
|
||||||
|
None => def_charset,
|
||||||
|
Some("safe") => SymbolCharset::SafeOnly,
|
||||||
|
Some("extended") => SymbolCharset::Extended,
|
||||||
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||||
|
};
|
||||||
|
generate_password(&GeneratorRequest::Random {
|
||||||
|
length: length.unwrap_or(def_length),
|
||||||
|
classes: def_classes,
|
||||||
|
symbol_charset,
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", output.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
106
crates/relicario-cli/src/commands/get.rs
Normal file
106
crates/relicario-cli/src/commands/get.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//! `relicario get` — print a single item, masking secrets unless `--show`.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
println!("ID: {}", item.id.as_str());
|
||||||
|
println!("Title: {}", item.title);
|
||||||
|
println!("Type: {:?}", item.r#type);
|
||||||
|
if let Some(g) = &item.group { println!("Group: {g}"); }
|
||||||
|
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||||
|
println!("Created: {}", crate::helpers::iso8601(item.created));
|
||||||
|
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||||
|
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||||
|
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||||
|
if let Some(t) = &l.totp {
|
||||||
|
if show {
|
||||||
|
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||||
|
} else {
|
||||||
|
println!("TOTP: **** (use --show to reveal)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.password.clone()
|
||||||
|
}
|
||||||
|
ItemCore::SecureNote(n) => {
|
||||||
|
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||||
|
else { println!("Body: ********"); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Identity(i) => {
|
||||||
|
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||||
|
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||||
|
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||||
|
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Card(c) => {
|
||||||
|
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||||
|
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
||||||
|
println!("Kind: {:?}", c.kind);
|
||||||
|
c.number.clone()
|
||||||
|
}
|
||||||
|
ItemCore::Key(k) => {
|
||||||
|
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||||
|
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
||||||
|
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
||||||
|
Some(k.key_material.clone())
|
||||||
|
}
|
||||||
|
ItemCore::Document(d) => {
|
||||||
|
println!("Filename: {}", d.filename);
|
||||||
|
println!("MIME: {}", d.mime_type);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Totp(t) => {
|
||||||
|
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||||
|
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||||
|
println!("Period: {}s", t.config.period_seconds);
|
||||||
|
println!("Digits: {}", t.config.digits);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(secret) = primary_secret {
|
||||||
|
if show {
|
||||||
|
println!("Secret: {}", secret.as_str());
|
||||||
|
} else {
|
||||||
|
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
||||||
|
}
|
||||||
|
if copy {
|
||||||
|
copy_to_clipboard_then_clear(&secret)?;
|
||||||
|
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
||||||
|
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
||||||
|
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
||||||
|
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
||||||
|
// and then rewrites the clipboard with empty string. Even if the user
|
||||||
|
// copies something else in the interim, we still overwrite once.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||||
|
if let Ok(mut cb) = Clipboard::new() {
|
||||||
|
let _ = cb.set_text(String::new());
|
||||||
|
drop(cleared_copy); // zeroize the detached copy
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
88
crates/relicario-cli/src/commands/import.rs
Normal file
88
crates/relicario-cli/src/commands/import.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! `relicario import` — currently only LastPass CSV is supported.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::ImportAction;
|
||||||
|
|
||||||
|
pub fn cmd_import(action: ImportAction) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::import_lastpass::parse_lastpass_csv;
|
||||||
|
|
||||||
|
let csv_bytes = fs::read(&csv_path)
|
||||||
|
.with_context(|| format!("failed to read CSV {}", csv_path.display()))?;
|
||||||
|
|
||||||
|
let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
// Print all warnings so the user sees why nothing imported.
|
||||||
|
for w in &warnings {
|
||||||
|
print_warning(w);
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"imported 0 items from {} — see warnings above",
|
||||||
|
csv_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let total = items.len();
|
||||||
|
let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);
|
||||||
|
|
||||||
|
for (idx, item) in items.iter().enumerate() {
|
||||||
|
vault.save_item(item)?;
|
||||||
|
manifest.upsert(item);
|
||||||
|
written_paths.push(format!("items/{}.enc", item.id.as_str()));
|
||||||
|
|
||||||
|
let n = idx + 1;
|
||||||
|
if n % 50 == 0 || n == total {
|
||||||
|
eprintln!("[{n}/{total}] importing...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
written_paths.push("manifest.enc".into());
|
||||||
|
|
||||||
|
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||||
|
let csv_filename = csv_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("lastpass.csv");
|
||||||
|
super::commit_paths(
|
||||||
|
&vault,
|
||||||
|
&format!("import: {} items from LastPass ({})", total, csv_filename),
|
||||||
|
&path_refs,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for w in &warnings {
|
||||||
|
print_warning(w);
|
||||||
|
}
|
||||||
|
// Counts only true skips, not partial imports. Coupled by convention to
|
||||||
|
// the parser's warning message strings: skip messages end in "— skipped",
|
||||||
|
// partial-import messages say "imported without TOTP" / "imported without URL".
|
||||||
|
// If a future warning uses the word "skipped" in any other sense, this filter
|
||||||
|
// will need to switch to an enum tag (see ImportWarning::message).
|
||||||
|
eprintln!(
|
||||||
|
"Imported {}, skipped {} (see warnings above)",
|
||||||
|
total,
|
||||||
|
warnings.iter().filter(|w| w.message.contains("skipped")).count()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
|
||||||
|
let prefix = match &w.title {
|
||||||
|
Some(t) => format!("row {} ({}):", w.row, t),
|
||||||
|
None => format!("row {}:", w.row),
|
||||||
|
};
|
||||||
|
eprintln!("warning: {prefix} {}", w.message);
|
||||||
|
}
|
||||||
98
crates/relicario-cli/src/commands/init.rs
Normal file
98
crates/relicario-cli/src/commands/init.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! `relicario init` — bootstrap a fresh vault in the current directory.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use relicario_core::{
|
||||||
|
derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
|
||||||
|
validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
|
||||||
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let root = std::env::current_dir()?;
|
||||||
|
let relicario_dir = root.join(".relicario");
|
||||||
|
if relicario_dir.exists() {
|
||||||
|
anyhow::bail!(".relicario/ already exists in {}", root.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passphrase with strength gate (audit H3).
|
||||||
|
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||||
|
// TTY prompt so integration tests can run without a real TTY.
|
||||||
|
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
||||||
|
};
|
||||||
|
let confirm = if crate::test_passphrase_override().is_some() {
|
||||||
|
passphrase.clone()
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
|
};
|
||||||
|
if passphrase.as_str() != confirm.as_str() {
|
||||||
|
anyhow::bail!("passphrases do not match");
|
||||||
|
}
|
||||||
|
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||||
|
anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image secret: 32 random bytes, embedded in the carrier.
|
||||||
|
let image_secret = {
|
||||||
|
let mut buf = Zeroizing::new([0u8; 32]);
|
||||||
|
OsRng.fill_bytes(buf.as_mut_slice());
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
let carrier = fs::read(&image)
|
||||||
|
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||||
|
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||||
|
fs::write(&output, &stego)
|
||||||
|
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||||
|
|
||||||
|
// Vault salt + KDF params.
|
||||||
|
let mut salt = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
|
||||||
|
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||||
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
|
fs::create_dir_all(root.join("items"))?;
|
||||||
|
fs::create_dir_all(root.join("attachments"))?;
|
||||||
|
fs::write(relicario_dir.join("salt"), salt)?;
|
||||||
|
fs::write(
|
||||||
|
relicario_dir.join("params.json"),
|
||||||
|
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?,
|
||||||
|
)?;
|
||||||
|
let manifest = Manifest::new();
|
||||||
|
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||||
|
let settings = VaultSettings::default();
|
||||||
|
fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;
|
||||||
|
|
||||||
|
// .gitignore excludes the reference image.
|
||||||
|
let fname = output.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))?
|
||||||
|
.to_string_lossy();
|
||||||
|
let gitignore = format!("{fname}\n");
|
||||||
|
fs::write(root.join(".gitignore"), gitignore)?;
|
||||||
|
|
||||||
|
// git init + initial commit via hardened wrapper.
|
||||||
|
crate::helpers::git_run(&root, &["init"], "init: git init")?;
|
||||||
|
let _ = crate::helpers::git_command(&root, &[
|
||||||
|
"add", ".gitignore", ".relicario/params.json",
|
||||||
|
".relicario/salt", "manifest.enc", "settings.enc",
|
||||||
|
]).status()?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["commit", "-m", "init: new Relicario vault (format v2)"],
|
||||||
|
"init: git commit",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Vault initialized at {}", root.display());
|
||||||
|
eprintln!("Reference image: {}", output.display());
|
||||||
|
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
334
crates/relicario-cli/src/commands/item_build.rs
Normal file
334
crates/relicario-cli/src/commands/item_build.rs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
//! Shared per-type item construction + interactive editing for both the
|
||||||
|
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
|
||||||
|
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::item::FieldHistoryEntry;
|
||||||
|
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
|
||||||
|
|
||||||
|
use crate::parse::base32_decode_lenient;
|
||||||
|
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||||
|
|
||||||
|
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
|
||||||
|
|
||||||
|
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
|
||||||
|
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
|
||||||
|
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
|
||||||
|
if from_stdin {
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
Ok(s.trim_end_matches(['\n', '\r']).to_string())
|
||||||
|
} else {
|
||||||
|
crate::prompt::prompt_secret(&format!("{label}: "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a multiline secret (key material, note body). Both paths read stdin
|
||||||
|
/// to EOF; the interactive path first prints `hint` to stderr.
|
||||||
|
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
|
||||||
|
if !from_stdin {
|
||||||
|
eprintln!("{hint}");
|
||||||
|
}
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
|
||||||
|
Ok(match s {
|
||||||
|
"credit" => CardKind::Credit,
|
||||||
|
"debit" => CardKind::Debit,
|
||||||
|
"gift" => CardKind::Gift,
|
||||||
|
"loyalty" => CardKind::Loyalty,
|
||||||
|
"other" => CardKind::Other,
|
||||||
|
other => anyhow::bail!("unknown card kind: {other}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
|
||||||
|
Ok(match s.to_ascii_lowercase().as_str() {
|
||||||
|
"sha1" => TotpAlgorithm::Sha1,
|
||||||
|
"sha256" => TotpAlgorithm::Sha256,
|
||||||
|
"sha512" => TotpAlgorithm::Sha512,
|
||||||
|
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
|
||||||
|
// mutates its core slice in place; history-tracked variants take the
|
||||||
|
// item's field_history map so they can record the prior value.
|
||||||
|
|
||||||
|
pub(crate) fn edit_login(
|
||||||
|
l: &mut relicario_core::item_types::LoginCore,
|
||||||
|
history: &mut FieldHistory,
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::item_types::{TotpConfig, TotpKind};
|
||||||
|
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||||
|
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||||
|
}
|
||||||
|
if prompt_yesno("Change password?")? {
|
||||||
|
let old = l.password.clone();
|
||||||
|
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||||
|
if let Some(old_pw) = old {
|
||||||
|
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(path) = totp_qr {
|
||||||
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
l.totp = Some(TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
});
|
||||||
|
eprintln!("TOTP secret set from QR image.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if prompt_yesno("Edit body?")? {
|
||||||
|
let old = n.body.clone();
|
||||||
|
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
|
||||||
|
n.body = Zeroizing::new(s);
|
||||||
|
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||||
|
if prompt_yesno("Change card number?")? {
|
||||||
|
let old = c.number.clone();
|
||||||
|
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||||
|
if let Some(o) = old {
|
||||||
|
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if prompt_yesno("Replace key material?")? {
|
||||||
|
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
|
||||||
|
let old = k.key_material.clone();
|
||||||
|
k.key_material = Zeroizing::new(s);
|
||||||
|
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_document_message() {
|
||||||
|
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||||
|
if prompt_yesno("Change TOTP secret?")? {
|
||||||
|
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||||
|
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||||
|
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||||
|
t.config.secret = Zeroizing::new(new_bytes);
|
||||||
|
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_login(
|
||||||
|
title: String, username: Option<String>, url: Option<String>,
|
||||||
|
password: Option<String>, password_stdin: bool, password_prompt: bool,
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
|
||||||
|
let parsed_url = match url {
|
||||||
|
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let password = if let Some(p) = password {
|
||||||
|
Some(Zeroizing::new(p))
|
||||||
|
} else if password_stdin {
|
||||||
|
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
|
||||||
|
} else if password_prompt {
|
||||||
|
Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let totp = if let Some(path) = totp_qr {
|
||||||
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
Some(TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6, period_seconds: 30, kind: TotpKind::Totp,
|
||||||
|
})
|
||||||
|
} else { None };
|
||||||
|
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::SecureNoteCore;
|
||||||
|
let body = match body {
|
||||||
|
Some(b) => b,
|
||||||
|
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
|
||||||
|
};
|
||||||
|
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_identity(
|
||||||
|
title: String, full_name: Option<String>, email: Option<String>,
|
||||||
|
phone: Option<String>, date_of_birth: Option<String>,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::IdentityCore;
|
||||||
|
let dob = match date_of_birth {
|
||||||
|
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||||
|
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
|
||||||
|
full_name, address: None, phone, email, date_of_birth: dob,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_card(
|
||||||
|
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
|
||||||
|
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::CardCore;
|
||||||
|
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
|
||||||
|
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
|
||||||
|
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
|
||||||
|
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
|
||||||
|
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
|
||||||
|
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
|
||||||
|
Ok(Item::new(title, ItemCore::Card(CardCore {
|
||||||
|
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_key(
|
||||||
|
title: String, label: Option<String>, algorithm: Option<String>,
|
||||||
|
public_key: Option<String>, material_stdin: bool,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::KeyCore;
|
||||||
|
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
|
||||||
|
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||||
|
Ok(Item::new(title, ItemCore::Key(KeyCore {
|
||||||
|
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn build_totp(
|
||||||
|
title: String, issuer: Option<String>, label: Option<String>,
|
||||||
|
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
|
||||||
|
let secret_b32 = match secret {
|
||||||
|
Some(s) => s,
|
||||||
|
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
|
||||||
|
};
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
Ok(Item::new(title, ItemCore::Totp(TotpCore {
|
||||||
|
config: TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
|
||||||
|
digits, period_seconds: period, kind: TotpKind::Totp,
|
||||||
|
},
|
||||||
|
issuer, label,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file and encrypt it as an attachment under `key`, deriving its display
|
||||||
|
/// metadata. The plaintext is held in a `Zeroizing` buffer so it is wiped after
|
||||||
|
/// encryption. Returns the encrypted blob plus (filename, mime_type, size).
|
||||||
|
pub(crate) fn encrypt_document_file(
|
||||||
|
path: &Path,
|
||||||
|
key: &Zeroizing<[u8; 32]>,
|
||||||
|
max_bytes: u64,
|
||||||
|
) -> Result<(EncryptedAttachment, String, String, u64)> {
|
||||||
|
use relicario_core::encrypt_attachment;
|
||||||
|
let bytes = Zeroizing::new(
|
||||||
|
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
|
||||||
|
);
|
||||||
|
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let mime_type = crate::parse::guess_mime(&filename);
|
||||||
|
Ok((enc, filename, mime_type, bytes.len() as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_document(
|
||||||
|
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
|
||||||
|
) -> Result<(Item, EncryptedAttachment)> {
|
||||||
|
use relicario_core::item_types::DocumentCore;
|
||||||
|
use relicario_core::AttachmentRef;
|
||||||
|
let (enc, filename, mime_type, size) = encrypt_document_file(&file, key, max_bytes)?;
|
||||||
|
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||||
|
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: enc.id.clone(),
|
||||||
|
}));
|
||||||
|
item.attachments.push(AttachmentRef {
|
||||||
|
id: enc.id.clone(), filename, mime_type, size, created: item.created,
|
||||||
|
});
|
||||||
|
Ok((item, enc))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn push_history(
|
||||||
|
history: &mut FieldHistory,
|
||||||
|
synthetic_key: &str,
|
||||||
|
old_value: zeroize::Zeroizing<String>,
|
||||||
|
) {
|
||||||
|
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||||||
|
// custom-field UUIDs can't collide).
|
||||||
|
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||||||
|
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||||||
|
value: old_value,
|
||||||
|
replaced_at: now_unix(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_kind_parses_known_values() {
|
||||||
|
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
|
||||||
|
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_kind_rejects_unknown() {
|
||||||
|
assert!(parse_card_kind("platinum").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totp_algorithm_is_case_insensitive() {
|
||||||
|
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totp_algorithm_rejects_unknown() {
|
||||||
|
assert!(parse_totp_algorithm("md5").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
102
crates/relicario-cli/src/commands/list.rs
Normal file
102
crates/relicario-cli/src/commands/list.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
//! `relicario list` and `relicario history` — both read-only browse paths.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_list(
|
||||||
|
type_filter: Option<String>,
|
||||||
|
group_filter: Option<String>,
|
||||||
|
tag_filter: Option<String>,
|
||||||
|
trashed: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::ItemType;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||||
|
None => None,
|
||||||
|
Some("login") => Some(ItemType::Login),
|
||||||
|
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
|
||||||
|
Some("identity") => Some(ItemType::Identity),
|
||||||
|
Some("card") => Some(ItemType::Card),
|
||||||
|
Some("key") => Some(ItemType::Key),
|
||||||
|
Some("document") => Some(ItemType::Document),
|
||||||
|
Some("totp") => Some(ItemType::Totp),
|
||||||
|
Some(other) => anyhow::bail!("unknown type filter: {other}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = manifest.items.values()
|
||||||
|
.filter(|e| {
|
||||||
|
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
|
||||||
|
})
|
||||||
|
.filter(|e| match parsed_type {
|
||||||
|
Some(t) => e.r#type == t,
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||||
|
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
eprintln!("(no items match)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||||
|
for e in entries {
|
||||||
|
let fav = if e.favorite { " *" } else { "" };
|
||||||
|
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_history(query: String, show: bool, field: Option<String>) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
println!("History for {} ({})", item.title, item.id.as_str());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Filter and sort the field-id keys so output is deterministic.
|
||||||
|
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
||||||
|
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
let mut printed_any = false;
|
||||||
|
for fid in keys {
|
||||||
|
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
||||||
|
if let Some(filter) = &field {
|
||||||
|
if display_name != filter && fid.0 != *filter { continue; }
|
||||||
|
}
|
||||||
|
let entries = &item.field_history[fid];
|
||||||
|
if entries.is_empty() { continue; }
|
||||||
|
printed_any = true;
|
||||||
|
|
||||||
|
println!("{display_name} ({} {})",
|
||||||
|
entries.len(),
|
||||||
|
if entries.len() == 1 { "entry" } else { "entries" });
|
||||||
|
for (i, e) in entries.iter().enumerate() {
|
||||||
|
let ts = crate::helpers::iso8601(e.replaced_at);
|
||||||
|
if show {
|
||||||
|
println!(" [{i}] {ts} {}", e.value.as_str());
|
||||||
|
} else {
|
||||||
|
println!(" [{i}] {ts} ********");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !printed_any {
|
||||||
|
if field.is_some() {
|
||||||
|
println!("no history for the requested field");
|
||||||
|
} else {
|
||||||
|
println!("no history captured for this item");
|
||||||
|
}
|
||||||
|
} else if !show {
|
||||||
|
println!("(use --show to reveal values)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
62
crates/relicario-cli/src/commands/mod.rs
Normal file
62
crates/relicario-cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
//! Per-command modules — one file per top-level subcommand.
|
||||||
|
//!
|
||||||
|
//! `main.rs` holds the clap surface (argument enums) and the dispatch
|
||||||
|
//! `match`; the actual command bodies live here. Helpers shared between
|
||||||
|
//! command modules (e.g. `commit_paths`, `resolve_query`) are defined in
|
||||||
|
//! this file as `pub(crate)` so siblings can pull them in via
|
||||||
|
//! `use crate::commands::*`.
|
||||||
|
|
||||||
|
pub mod add;
|
||||||
|
pub mod attach;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod device;
|
||||||
|
pub mod edit;
|
||||||
|
pub mod generate;
|
||||||
|
pub mod get;
|
||||||
|
pub mod import;
|
||||||
|
pub mod item_build;
|
||||||
|
pub mod org;
|
||||||
|
pub mod init;
|
||||||
|
pub mod list;
|
||||||
|
pub mod rate;
|
||||||
|
pub mod recovery_qr;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod status;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod trash;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub(crate) fn commit_paths(
|
||||||
|
vault: &crate::session::UnlockedVault,
|
||||||
|
message: &str,
|
||||||
|
paths: &[&str],
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut args: Vec<&str> = vec!["add"];
|
||||||
|
args.extend_from_slice(paths);
|
||||||
|
crate::helpers::git_run(vault.root(), &args, &format!("commit \"{message}\": git add"))?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["commit", "-m", message],
|
||||||
|
&format!("commit \"{message}\": git commit"),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_query<'a>(
|
||||||
|
manifest: &'a relicario_core::Manifest,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<&'a relicario_core::ManifestEntry> {
|
||||||
|
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
let hits: Vec<_> = manifest.search(query);
|
||||||
|
match hits.len() {
|
||||||
|
0 => anyhow::bail!("no item matches `{query}`"),
|
||||||
|
1 => Ok(hits[0]),
|
||||||
|
_ => {
|
||||||
|
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
|
||||||
|
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1286
crates/relicario-cli/src/commands/org.rs
Normal file
1286
crates/relicario-cli/src/commands/org.rs
Normal file
File diff suppressed because it is too large
Load Diff
28
crates/relicario-cli/src/commands/rate.rs
Normal file
28
crates/relicario-cli/src/commands/rate.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//! `relicario rate` — score a passphrase via zxcvbn.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_rate(passphrase: String) -> Result<()> {
|
||||||
|
let pw: String = if passphrase == "-" {
|
||||||
|
use std::io::BufRead;
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut line = String::new();
|
||||||
|
stdin.lock().read_line(&mut line)?;
|
||||||
|
line.trim_end_matches(&['\r', '\n'][..]).to_string()
|
||||||
|
} else {
|
||||||
|
passphrase
|
||||||
|
};
|
||||||
|
let est = relicario_core::generators::rate_passphrase(&pw);
|
||||||
|
let label = match est.score {
|
||||||
|
0 => "very weak",
|
||||||
|
1 => "weak",
|
||||||
|
2 => "fair",
|
||||||
|
3 => "good",
|
||||||
|
4 => "strong",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
println!("score: {}/4 ({})", est.score, label);
|
||||||
|
println!("guesses: ~10^{:.1}", est.guesses_log10);
|
||||||
|
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
//! `relicario recovery-qr {generate,unwrap}` — last-resort vault-key escape hatch.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::RecoveryQrCmd;
|
||||||
|
|
||||||
|
pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
|
||||||
|
match cmd {
|
||||||
|
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
|
||||||
|
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_recovery_qr_generate() -> Result<()> {
|
||||||
|
use relicario_core::{generate_recovery_qr, imgsecret};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let image_path = crate::session::get_image_path()?;
|
||||||
|
let image_bytes = std::fs::read(&image_path)
|
||||||
|
.with_context(|| format!("read reference image {}", image_path.display()))?;
|
||||||
|
let image_secret = imgsecret::extract(&image_bytes)
|
||||||
|
.context("extract image secret")?;
|
||||||
|
|
||||||
|
let passphrase = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Enter vault passphrase: ")
|
||||||
|
.context("read passphrase")?
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
use qrcode::{EcLevel, QrCode, render::unicode};
|
||||||
|
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
|
||||||
|
.expect("valid payload");
|
||||||
|
let image = code
|
||||||
|
.render::<unicode::Dense1x2>()
|
||||||
|
.dark_color(unicode::Dense1x2::Dark)
|
||||||
|
.light_color(unicode::Dense1x2::Light)
|
||||||
|
.build();
|
||||||
|
println!("{image}");
|
||||||
|
println!("Recovery QR generated. Print or photograph this code and store it securely.");
|
||||||
|
println!("The QR has NOT been saved to disk.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_recovery_qr_unwrap() -> Result<()> {
|
||||||
|
use relicario_core::unwrap_recovery_qr;
|
||||||
|
use std::io::BufRead;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
println!("Paste the base64 recovery QR payload and press Enter:");
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let payload_b64 = stdin.lock().lines().next()
|
||||||
|
.context("no input")??;
|
||||||
|
let payload_b64 = payload_b64.trim().to_owned();
|
||||||
|
|
||||||
|
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
|
||||||
|
|
||||||
|
let passphrase = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Enter passphrase: ")
|
||||||
|
.context("read passphrase")?
|
||||||
|
);
|
||||||
|
|
||||||
|
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
println!("image_secret: {}", hex::encode(secret.as_ref()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
98
crates/relicario-cli/src/commands/settings.rs
Normal file
98
crates/relicario-cli/src/commands/settings.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! `relicario settings {show, trash-retention, history-retention, attachment-cap, generator-defaults}`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::SettingsAction;
|
||||||
|
|
||||||
|
pub fn cmd_settings(action: SettingsAction) -> Result<()> {
|
||||||
|
use relicario_core::{
|
||||||
|
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||||
|
SymbolCharset, TrashRetention,
|
||||||
|
};
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut settings = vault.load_settings()?;
|
||||||
|
|
||||||
|
match action {
|
||||||
|
SettingsAction::Show => {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
SettingsAction::TrashRetention { days, forever } => {
|
||||||
|
settings.trash_retention = match (days, forever) {
|
||||||
|
(Some(d), false) => TrashRetention::Days(d),
|
||||||
|
(None, true) => TrashRetention::Forever,
|
||||||
|
_ => anyhow::bail!("specify exactly one of --days or --forever"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SettingsAction::HistoryRetention { last_n, days, forever } => {
|
||||||
|
settings.field_history_retention = match (last_n, days, forever) {
|
||||||
|
(Some(n), None, false) => HistoryRetention::LastN(n),
|
||||||
|
(None, Some(d), false) => HistoryRetention::Days(d),
|
||||||
|
(None, None, true) => HistoryRetention::Forever,
|
||||||
|
_ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SettingsAction::AttachmentCap {
|
||||||
|
per_attachment_max_bytes, per_item_max_count,
|
||||||
|
per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
|
||||||
|
} => {
|
||||||
|
if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
|
||||||
|
if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
|
||||||
|
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
|
||||||
|
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
|
||||||
|
}
|
||||||
|
SettingsAction::GeneratorDefaults {
|
||||||
|
random, bip39, length, words, symbols, separator,
|
||||||
|
} => {
|
||||||
|
// Decide target mode: explicit flag wins, else preserve current.
|
||||||
|
let target_bip39 = if random { false }
|
||||||
|
else if bip39 { true }
|
||||||
|
else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };
|
||||||
|
|
||||||
|
// Pull existing fields where compatible, else seed with sensible
|
||||||
|
// defaults (kept in sync with `GeneratorRequest::default()`).
|
||||||
|
let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
|
||||||
|
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||||
|
(*length, *classes, symbol_charset.clone())
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
20,
|
||||||
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
SymbolCharset::SafeOnly,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
|
||||||
|
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||||
|
(*word_count, separator.clone(), *capitalization)
|
||||||
|
}
|
||||||
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.generator_defaults = if target_bip39 {
|
||||||
|
GeneratorRequest::Bip39 {
|
||||||
|
word_count: words.unwrap_or(cur_words),
|
||||||
|
separator: separator.unwrap_or(cur_sep),
|
||||||
|
capitalization: cur_cap,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let charset = match symbols.as_deref() {
|
||||||
|
None => cur_charset,
|
||||||
|
Some("safe") => SymbolCharset::SafeOnly,
|
||||||
|
Some("extended") => SymbolCharset::Extended,
|
||||||
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||||
|
};
|
||||||
|
GeneratorRequest::Random {
|
||||||
|
length: length.unwrap_or(cur_length),
|
||||||
|
classes: cur_classes,
|
||||||
|
symbol_charset: charset,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.save_settings(&settings)?;
|
||||||
|
super::commit_paths(&vault, "settings: update", &["settings.enc"])?;
|
||||||
|
eprintln!("Settings updated.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
52
crates/relicario-cli/src/commands/status.rs
Normal file
52
crates/relicario-cli/src/commands/status.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! `relicario status` — vault-level summary (counts, last commit, last backup).
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_status() -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let root = vault.root().to_path_buf();
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let total_items = manifest.items.len();
|
||||||
|
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
||||||
|
let active_items = total_items - trashed_items;
|
||||||
|
|
||||||
|
let (attachment_count, attachment_bytes) = manifest.items.values()
|
||||||
|
.flat_map(|e| e.attachment_summaries.iter())
|
||||||
|
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||||
|
|
||||||
|
let last_commit = crate::helpers::git_command(&root, &[
|
||||||
|
"log", "-1", "--pretty=format:%h %s",
|
||||||
|
]).output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "(no commits)".into());
|
||||||
|
|
||||||
|
// Last backup age (read from marker written by cmd_backup_export).
|
||||||
|
let last_backup_path = vault.root().join(".relicario").join("last_backup");
|
||||||
|
let last_backup_str = if last_backup_path.exists() {
|
||||||
|
let line = std::fs::read_to_string(&last_backup_path)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
// Parse the ISO-8601 we wrote in cmd_backup_export.
|
||||||
|
match chrono::DateTime::parse_from_rfc3339(&line) {
|
||||||
|
Ok(then) => {
|
||||||
|
let now = relicario_core::now_unix();
|
||||||
|
let age = now - then.timestamp();
|
||||||
|
crate::helpers::humanize_age(age.max(0))
|
||||||
|
}
|
||||||
|
Err(_) => "unknown".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"never".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Vault: {}", root.display());
|
||||||
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||||
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||||
|
println!("Last commit: {last_commit}");
|
||||||
|
println!("Last export: {last_backup_str}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
11
crates/relicario-cli/src/commands/sync.rs
Normal file
11
crates/relicario-cli/src/commands/sync.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! `relicario sync` — pull --rebase + push.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_sync() -> Result<()> {
|
||||||
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?;
|
||||||
|
crate::helpers::git_run(&root, &["push"], "sync: git push")?;
|
||||||
|
eprintln!("Sync complete.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
149
crates/relicario-cli/src/commands/trash.rs
Normal file
149
crates/relicario-cli/src/commands/trash.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//! Trash umbrella: `rm` (soft-delete), `restore`, `purge` (permanent),
|
||||||
|
//! `trash list` / `trash empty`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::TrashAction;
|
||||||
|
|
||||||
|
pub fn cmd_rm(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
item.soft_delete();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
|
eprintln!("Moved to trash: {}", item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_restore(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
item.restore();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
|
eprintln!("Restored: {}", item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem-only purge: removes the item.enc, attachments/<id>/, and updates
|
||||||
|
/// the manifest in memory. Returns the relative paths the caller must stage
|
||||||
|
/// via `git rm` after the loop. Does NOT invoke any git commands — the caller
|
||||||
|
/// batches them.
|
||||||
|
pub(super) fn purge_item_filesystem(
|
||||||
|
vault: &crate::session::UnlockedVault,
|
||||||
|
manifest: &mut relicario_core::Manifest,
|
||||||
|
id: &relicario_core::ItemId,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
use std::{fs, io::ErrorKind};
|
||||||
|
|
||||||
|
let item_rel = format!("items/{}.enc", id.as_str());
|
||||||
|
let att_rel = format!("attachments/{}", id.as_str());
|
||||||
|
|
||||||
|
let ignore_missing = |r: std::io::Result<()>| -> Result<()> {
|
||||||
|
match r {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ignore_missing(fs::remove_file(vault.item_path(id)))?;
|
||||||
|
ignore_missing(fs::remove_dir_all(vault.root().join("attachments").join(id.as_str())))?;
|
||||||
|
manifest.remove(id);
|
||||||
|
|
||||||
|
eprintln!("Purged: {title}");
|
||||||
|
Ok(vec![item_rel, att_rel])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_purge(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let title = entry.title.clone();
|
||||||
|
let _ = entry;
|
||||||
|
|
||||||
|
let paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
||||||
|
crate::helpers::git_rm(vault.root(), &paths, &format!("{purge_ctx}: git rm"))?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["add", "manifest.enc"],
|
||||||
|
&format!("{purge_ctx}: git add manifest.enc"),
|
||||||
|
)?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
|
||||||
|
&format!("{purge_ctx}: git commit"),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_trash(action: TrashAction) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
TrashAction::List => super::list::cmd_list(None, None, None, true),
|
||||||
|
TrashAction::Empty => cmd_trash_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_trash_empty() -> Result<()> {
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let settings = vault.load_settings()?;
|
||||||
|
let now = now_unix();
|
||||||
|
|
||||||
|
let purgeable: Vec<_> = manifest.items.values()
|
||||||
|
.filter(|e| match e.trashed_at {
|
||||||
|
Some(t) => settings.trash_retention.should_purge(t, now),
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
.map(|e| (e.id.clone(), e.title.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if purgeable.is_empty() {
|
||||||
|
eprintln!("nothing past retention window");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_paths: Vec<String> = Vec::new();
|
||||||
|
let purged_count = purgeable.len();
|
||||||
|
for (id, title) in purgeable {
|
||||||
|
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||||
|
all_paths.append(&mut paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
crate::helpers::git_rm(vault.root(), &all_paths, "trash empty: git rm")?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["add", "manifest.enc"],
|
||||||
|
"trash empty: git add manifest.enc",
|
||||||
|
)?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
|
||||||
|
"trash empty: git commit",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Emptied trash: {} item(s)", purged_count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -99,6 +99,48 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
|||||||
Ok(Zeroizing::new(key))
|
Ok(Zeroizing::new(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the active device's ed25519 public key (OpenSSH single-line format,
|
||||||
|
/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`.
|
||||||
|
///
|
||||||
|
/// Errors if no device is selected (`devices/current` missing/empty) — the
|
||||||
|
/// caller should hint the user to run `relicario device add` first.
|
||||||
|
pub fn current_device_pubkey() -> Result<String> {
|
||||||
|
let name = current_device()?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
|
||||||
|
let path = device_dir(&name)?.join("signing.pub");
|
||||||
|
let pubkey = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read signing.pub for device '{name}'"))?;
|
||||||
|
let trimmed = pubkey.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
anyhow::bail!("signing.pub for device '{name}' is empty");
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the active device's 32-byte ed25519 seed from `signing.key`
|
||||||
|
/// (OpenSSH private-key format).
|
||||||
|
///
|
||||||
|
/// The seed is the secret scalar used to sign org commits and to unwrap the
|
||||||
|
/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no
|
||||||
|
/// device is selected, the key file is unreadable, or the key is not ed25519.
|
||||||
|
pub fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let name = current_device()?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
|
||||||
|
// load_signing_key reads signing.key as OpenSSH private-key text.
|
||||||
|
let pem = load_signing_key(&name)?;
|
||||||
|
let private = ssh_key::PrivateKey::from_openssh(pem.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?;
|
||||||
|
let keypair = private
|
||||||
|
.key_data()
|
||||||
|
.ed25519()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?;
|
||||||
|
// Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7
|
||||||
|
// private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped.
|
||||||
|
let mut seed = Zeroizing::new([0u8; 32]);
|
||||||
|
seed.copy_from_slice(keypair.private.as_ref());
|
||||||
|
Ok(seed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Load the deploy private key for a device.
|
/// Load the deploy private key for a device.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
@@ -127,6 +169,53 @@ pub fn delete_device_keys(name: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod seed_helper_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
// dirs::config_dir() reads process-wide env; serialize these tests.
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_device_seed_and_pubkey_round_trip() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
|
||||||
|
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
|
||||||
|
|
||||||
|
// Generate a real ed25519 device keypair (OpenSSH text) via core.
|
||||||
|
let (private_openssh, public_openssh) =
|
||||||
|
relicario_core::device::generate_keypair().unwrap();
|
||||||
|
|
||||||
|
// Lay out devices/test-dev/{signing.key,signing.pub} + devices/current.
|
||||||
|
let dir = device_dir("test-dev").unwrap();
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap();
|
||||||
|
std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap();
|
||||||
|
set_current_device("test-dev").unwrap();
|
||||||
|
|
||||||
|
// pubkey helper returns exactly the stored OpenSSH public line.
|
||||||
|
let got_pub = current_device_pubkey().unwrap();
|
||||||
|
assert_eq!(got_pub.trim(), public_openssh.trim());
|
||||||
|
|
||||||
|
// seed helper returns the 32-byte ed25519 seed; re-derive the public
|
||||||
|
// key from it and confirm it matches.
|
||||||
|
let seed = current_device_seed().unwrap();
|
||||||
|
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||||
|
let derived = signing.verifying_key();
|
||||||
|
let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap();
|
||||||
|
let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref();
|
||||||
|
assert_eq!(derived.as_bytes().as_slice(), parsed_bytes);
|
||||||
|
|
||||||
|
// restore env
|
||||||
|
match prev_xdg {
|
||||||
|
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
||||||
|
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Configure git in `vault_root` to:
|
/// Configure git in `vault_root` to:
|
||||||
/// - sign commits with the device's signing key (SSH format)
|
/// - sign commits with the device's signing key (SSH format)
|
||||||
/// - push via SSH using the device's deploy key
|
/// - push via SSH using the device's deploy key
|
||||||
|
|||||||
@@ -55,6 +55,47 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
|||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run `git <args>` in `repo` with the same hardening as `git_command`,
|
||||||
|
/// capturing stdout/stderr and reproducing them on failure so the caller
|
||||||
|
/// sees git's exact diagnostic instead of just a verb.
|
||||||
|
///
|
||||||
|
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
|
||||||
|
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
|
||||||
|
/// identifiable from the error alone.
|
||||||
|
///
|
||||||
|
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
|
||||||
|
/// (so live progress disappears during long-running fetches/pushes) but the
|
||||||
|
/// captured chunk is replayed verbatim on failure. The win is that
|
||||||
|
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
|
||||||
|
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
|
||||||
|
/// instead of one-line "git X failed" bails. Use `git_command` directly when
|
||||||
|
/// live streaming is required.
|
||||||
|
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||||
|
let output = git_command(repo, args)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("{context}: failed to spawn git"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
if !output.stdout.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stdout));
|
||||||
|
}
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
bail!("{context}: git failed ({})", output.status);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
|
||||||
|
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
|
||||||
|
/// leave the manifest entry without the corresponding `items/<id>.enc` on
|
||||||
|
/// disk, and we want the rm to succeed regardless.
|
||||||
|
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
|
||||||
|
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
|
||||||
|
args.extend(paths.iter().map(String::as_str));
|
||||||
|
git_run(repo, &args, context)
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||||
/// a numeric string.
|
/// a numeric string.
|
||||||
@@ -95,6 +136,30 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
|||||||
vault_dir.join(".relicario").join("groups.cache")
|
vault_dir.join(".relicario").join("groups.cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collect all non-empty group names from the manifest and write them to the
|
||||||
|
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
||||||
|
/// candidates without prompting for the vault passphrase.
|
||||||
|
///
|
||||||
|
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||||
|
/// not a correctness problem.
|
||||||
|
///
|
||||||
|
/// Visibility note: this is `pub(crate)` so only `session::after_manifest_change`
|
||||||
|
/// can call it. The Plan B Phase 4 done-criterion requires every mutating
|
||||||
|
/// handler to funnel through the wrapper — exposing this helper to commands/
|
||||||
|
/// would let a caller refresh the cache without updating the manifest, breaking
|
||||||
|
/// the invariant.
|
||||||
|
pub(crate) fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
||||||
|
let mut set = std::collections::BTreeSet::<String>::new();
|
||||||
|
for entry in manifest.items.values() {
|
||||||
|
if let Some(g) = entry.group.as_ref() {
|
||||||
|
if !g.is_empty() {
|
||||||
|
set.insert(g.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = write_groups_cache(vault_dir, &set);
|
||||||
|
}
|
||||||
|
|
||||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||||
/// suppresses the write (developer debugging tool). In release builds the env
|
/// suppresses the write (developer debugging tool). In release builds the env
|
||||||
@@ -220,6 +285,24 @@ mod tests {
|
|||||||
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
|
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_run_bails_with_context_on_failure() {
|
||||||
|
// Empty tempdir — `git status` will fail with "not a git repository".
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(msg.contains("test_ctx"), "context not in error: {msg}");
|
||||||
|
assert!(msg.contains("git failed"), "missing failure marker: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_run_succeeds_for_a_zero_exit_command() {
|
||||||
|
// `git --version` always succeeds and is independent of cwd.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
git_run(tmp.path(), &["--version"], "version probe")
|
||||||
|
.expect("git --version should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn humanize_age_buckets() {
|
fn humanize_age_buckets() {
|
||||||
assert_eq!(humanize_age(0), "just now");
|
assert_eq!(humanize_age(0), "just now");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
362
crates/relicario-cli/src/org_session.rs
Normal file
362
crates/relicario-cli/src/org_session.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
//! Unlocked org vault session: holds the org master key for the duration of a
|
||||||
|
//! CLI invocation.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
|
||||||
|
AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest,
|
||||||
|
OrgMember, OrgMembers, OrgMeta,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Default per-attachment cap for org vaults. Org vaults have no settings.enc,
|
||||||
|
/// so this mirrors the personal-vault default
|
||||||
|
/// `AttachmentCaps::per_attachment_max_bytes` at
|
||||||
|
/// crates/relicario-core/src/settings.rs:116.
|
||||||
|
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub struct UnlockedOrgVault {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub org_key: Zeroizing<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnlockedOrgVault {
|
||||||
|
pub fn root(&self) -> &Path { &self.root }
|
||||||
|
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key }
|
||||||
|
|
||||||
|
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
|
||||||
|
|
||||||
|
/// Collection-scoped item path: `items/<collection-slug>/<id>.enc`.
|
||||||
|
/// The leading slug segment is what the pre-receive hook authorizes against
|
||||||
|
/// members.json — it never decrypts the blob. The slug must be non-empty and
|
||||||
|
/// already validated.
|
||||||
|
pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join("items")
|
||||||
|
.join(collection_slug)
|
||||||
|
.join(format!("{}.enc", id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn member_key_path(&self, id: &MemberId) -> PathBuf {
|
||||||
|
self.root.join("keys").join(format!("{}.enc", id.as_str()))
|
||||||
|
}
|
||||||
|
pub fn members_path(&self) -> PathBuf { self.root.join("members.json") }
|
||||||
|
pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") }
|
||||||
|
// OrgMeta accessors — part of the UnlockedOrgVault path/loader API surface
|
||||||
|
// (parallel to members_path/collections_path + load_members), retained for
|
||||||
|
// completeness. No command consumes org.json yet; surfacing the org
|
||||||
|
// name/id in `org status` is a tracked follow-up, so allow until then.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") }
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn load_meta(&self) -> Result<OrgMeta> {
|
||||||
|
let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?;
|
||||||
|
Ok(serde_json::from_str(&s).context("parse org.json")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_members(&self) -> Result<OrgMembers> {
|
||||||
|
let s = fs::read_to_string(self.members_path()).context("read members.json")?;
|
||||||
|
Ok(serde_json::from_str(&s).context("parse members.json")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_members(&self, members: &OrgMembers) -> Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(members)?;
|
||||||
|
atomic_write(&self.members_path(), json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_collections(&self) -> Result<OrgCollections> {
|
||||||
|
let s = fs::read_to_string(self.collections_path()).context("read collections.json")?;
|
||||||
|
Ok(serde_json::from_str(&s).context("parse collections.json")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(collections)?;
|
||||||
|
atomic_write(&self.collections_path(), json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_manifest(&self) -> Result<OrgManifest> {
|
||||||
|
let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?;
|
||||||
|
Ok(decrypt_org_manifest(&bytes, &self.org_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> {
|
||||||
|
let bytes = encrypt_org_manifest(manifest, &self.org_key)?;
|
||||||
|
atomic_write(&self.manifest_path(), &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt + write an item under its collection directory, creating the
|
||||||
|
/// directory if needed. Returns the repo-relative path for git staging.
|
||||||
|
pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result<String> {
|
||||||
|
let path = self.item_path(collection_slug, &item.id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let bytes = encrypt_item(item, &self.org_key)?;
|
||||||
|
atomic_write(&path, &bytes)?;
|
||||||
|
Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read + decrypt an item from its collection directory.
|
||||||
|
pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result<Item> {
|
||||||
|
let path = self.item_path(collection_slug, id);
|
||||||
|
let bytes = fs::read(&path)
|
||||||
|
.with_context(|| format!("read item {}", path.display()))?;
|
||||||
|
Ok(decrypt_item(&bytes, &self.org_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an item blob. Missing file is not an error (partial-write
|
||||||
|
/// recovery, same as the personal-vault purge path).
|
||||||
|
pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> {
|
||||||
|
let path = self.item_path(collection_slug, id);
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(anyhow::Error::from(e)
|
||||||
|
.context(format!("delete {}", path.display()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
|
||||||
|
self.root.join("attachments").join(collection_slug)
|
||||||
|
.join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt-already-done blob: persist it and return the repo-relative path for git staging.
|
||||||
|
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
|
||||||
|
let path = self.attachment_path(collection_slug, item_id, &enc.id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
atomic_write(&path, &enc.bytes)?;
|
||||||
|
Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
|
||||||
|
let path = self.attachment_path(collection_slug, item_id, att_id);
|
||||||
|
let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
|
||||||
|
Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an item's whole attachment directory. Missing dir is NOT an error
|
||||||
|
/// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
|
||||||
|
pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
|
||||||
|
let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
|
||||||
|
match fs::remove_dir_all(&dir) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bail unless `member` has `slug` in their collection grants. The slug
|
||||||
|
/// existence check is done separately by the caller against collections.json.
|
||||||
|
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
|
||||||
|
if member.collections.iter().any(|c| c == slug) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load members.json and find the caller's member entry by matching the
|
||||||
|
/// current device's ed25519 fingerprint against each member's pubkey
|
||||||
|
/// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality)
|
||||||
|
/// tolerates comment/whitespace differences in the serialized key.
|
||||||
|
pub fn current_member(&self) -> Result<relicario_core::OrgMember> {
|
||||||
|
let device_fp = current_device_fingerprint()?;
|
||||||
|
let members = self.load_members()?;
|
||||||
|
members
|
||||||
|
.members
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| {
|
||||||
|
relicario_core::fingerprint(&m.ed25519_pubkey)
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
== Some(device_fp.as_str())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"your device key is not registered in this org — ask an admin to run `org add-member`"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value.
|
||||||
|
pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result<PathBuf> {
|
||||||
|
if let Some(d) = dir_flag {
|
||||||
|
return Ok(d.to_path_buf());
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") {
|
||||||
|
return Ok(PathBuf::from(v));
|
||||||
|
}
|
||||||
|
bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir <path>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an org vault: locate the root, read members.json to find the caller's
|
||||||
|
/// member entry (by ed25519 fingerprint), then unwrap their keys/<id>.enc to
|
||||||
|
/// recover the org master key.
|
||||||
|
pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result<UnlockedOrgVault> {
|
||||||
|
let root = org_dir(dir_flag)?;
|
||||||
|
|
||||||
|
let device_fp = current_device_fingerprint()?;
|
||||||
|
let members_json = fs::read_to_string(root.join("members.json"))
|
||||||
|
.context("read members.json — is this an org vault?")?;
|
||||||
|
let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?;
|
||||||
|
let member = members
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.find(|m| {
|
||||||
|
relicario_core::fingerprint(&m.ed25519_pubkey)
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
== Some(device_fp.as_str())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?;
|
||||||
|
|
||||||
|
// Load this member's wrapped key blob.
|
||||||
|
let key_path = root
|
||||||
|
.join("keys")
|
||||||
|
.join(format!("{}.enc", member.member_id.as_str()));
|
||||||
|
let wrapped =
|
||||||
|
fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
|
||||||
|
|
||||||
|
// Recover the device ed25519 seed and unwrap.
|
||||||
|
let seed = crate::device::current_device_seed()?;
|
||||||
|
let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?;
|
||||||
|
|
||||||
|
Ok(UnlockedOrgVault { root, org_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenSSH SHA-256 fingerprint of the active device's signing key.
|
||||||
|
fn current_device_fingerprint() -> Result<String> {
|
||||||
|
let name = crate::device::current_device()?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
|
||||||
|
let pub_path = crate::device::device_dir(&name)?.join("signing.pub");
|
||||||
|
let pubkey = fs::read_to_string(&pub_path)
|
||||||
|
.with_context(|| format!("read {}", pub_path.display()))?;
|
||||||
|
Ok(relicario_core::fingerprint(pubkey.trim())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the active device's ed25519 seed (the 32-byte private scalar source)
|
||||||
|
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||||
|
let mut tmp = path.as_os_str().to_owned();
|
||||||
|
tmp.push(".tmp");
|
||||||
|
let tmp = PathBuf::from(tmp);
|
||||||
|
fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `git <args>` in the org repo, capturing output and replaying it on
|
||||||
|
/// failure. Unlike `crate::helpers::git_run`, this does NOT inject
|
||||||
|
/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be
|
||||||
|
/// signed (the pre-receive hook verifies every commit's signature), and the
|
||||||
|
/// repo's signing config is established by `configure_git_signing` during
|
||||||
|
/// `org init`.
|
||||||
|
pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.current_dir(root)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("{context}: failed to spawn git"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
if !output.stdout.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stdout));
|
||||||
|
}
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
anyhow::bail!("{context}: git failed ({})", output.status);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
fs::create_dir_all(root.join("items")).unwrap();
|
||||||
|
fs::create_dir_all(root.join("keys")).unwrap();
|
||||||
|
let vault = UnlockedOrgVault { root, org_key: key };
|
||||||
|
(dir, vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlocked_org_vault_paths() {
|
||||||
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
assert_eq!(vault.manifest_path(), root.join("manifest.enc"));
|
||||||
|
assert_eq!(
|
||||||
|
vault.member_key_path(&MemberId("abc0def1abc0def1".into())),
|
||||||
|
root.join("keys/abc0def1abc0def1.enc")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())),
|
||||||
|
root.join("items/prod/0123456789abcdef.enc")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_manifest() {
|
||||||
|
let key = Zeroizing::new([0xAAu8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let _ = dir; // keep alive
|
||||||
|
let mut m = OrgManifest::new();
|
||||||
|
m.entries.push(relicario_core::OrgManifestEntry {
|
||||||
|
id: relicario_core::ItemId::new(),
|
||||||
|
r#type: relicario_core::ItemType::SecureNote,
|
||||||
|
title: "test".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "prod".into(),
|
||||||
|
});
|
||||||
|
vault.save_manifest(&m).unwrap();
|
||||||
|
let loaded = vault.load_manifest().unwrap();
|
||||||
|
assert_eq!(loaded.entries.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_round_trip_collection_scoped() {
|
||||||
|
use relicario_core::encrypt_attachment;
|
||||||
|
let key = Zeroizing::new([7u8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let _ = dir; // keep tempdir alive
|
||||||
|
let item_id = ItemId::new();
|
||||||
|
let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
|
||||||
|
let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
|
||||||
|
assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
|
||||||
|
let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
|
||||||
|
assert_eq!(got.as_slice(), b"hello world");
|
||||||
|
vault.remove_item_attachments("eng", &item_id).unwrap();
|
||||||
|
assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_members() {
|
||||||
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let _ = dir;
|
||||||
|
let members = OrgMembers::new();
|
||||||
|
vault.save_members(&members).unwrap();
|
||||||
|
let loaded = vault.load_members().unwrap();
|
||||||
|
assert_eq!(loaded.schema_version, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/relicario-cli/src/parse.rs
Normal file
19
crates/relicario-cli/src/parse.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//! Thin shims over `relicario-core`'s migrated parsers, kept here so existing
|
||||||
|
//! CLI callsites need no import churn. Plan B Phase 7 moved the bodies into
|
||||||
|
//! `relicario_core::{time::MonthYear::parse, base32::decode_rfc4648_lenient,
|
||||||
|
//! mime::guess_for_extension}`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use relicario_core::MonthYear;
|
||||||
|
|
||||||
|
pub(crate) fn parse_month_year(s: &str) -> Result<MonthYear> {
|
||||||
|
Ok(MonthYear::parse(s)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn guess_mime(filename: &str) -> String {
|
||||||
|
relicario_core::mime::guess_for_extension(filename).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
|
||||||
|
Ok(relicario_core::base32::decode_rfc4648_lenient(s)?)
|
||||||
|
}
|
||||||
182
crates/relicario-cli/src/prompt.rs
Normal file
182
crates/relicario-cli/src/prompt.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
//! Interactive prompt helpers for the CLI.
|
||||||
|
//!
|
||||||
|
//! `prompt_secret` reads a masked secret from the TTY (honouring
|
||||||
|
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject
|
||||||
|
//! secrets); the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
||||||
|
//! used by the edit handlers to keep current values when the user hits enter
|
||||||
|
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
|
||||||
|
//! CLI-flag value through the same path so command handlers can use one call
|
||||||
|
//! site whether the value came from the command line or an interactive prompt.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
|
/// unavailable in assert_cmd-spawned children).
|
||||||
|
pub(crate) fn prompt_secret(label: &str) -> Result<String> {
|
||||||
|
if let Some(s) = crate::test_item_secret_override() {
|
||||||
|
return Ok(s);
|
||||||
|
}
|
||||||
|
rpassword::prompt_password(label).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_required_line<R: BufRead>(reader: &mut R, label: &str) -> Result<String> {
|
||||||
|
eprint!("{label}: ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
reader.read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||||
|
Ok(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<String>> {
|
||||||
|
eprint!("{label} (leave blank to skip): ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
reader.read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||||
|
eprint!("{label} [{current}]: ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
|
||||||
|
let display = current.unwrap_or("(none)");
|
||||||
|
eprint!("{label} [{display}]: ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
|
||||||
|
eprint!("{label} [y/N] ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag<T>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
) -> Result<T> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||||
|
prompt_or_flag_with_reader(flag, label, parser, &mut reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag_optional<T>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
) -> Result<Option<T>> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||||
|
prompt_or_flag_optional_with_reader(flag, label, parser, &mut reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag_with_reader<T, R: BufRead>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
reader: &mut R,
|
||||||
|
) -> Result<T> {
|
||||||
|
if let Some(t) = flag {
|
||||||
|
return Ok(t);
|
||||||
|
}
|
||||||
|
let line = read_required_line(reader, label)?;
|
||||||
|
parser(&line)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag_optional_with_reader<T, R: BufRead>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
reader: &mut R,
|
||||||
|
) -> Result<Option<T>> {
|
||||||
|
if let Some(t) = flag {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
match read_optional_line(reader, label)? {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(line) => parser(&line).map(Some),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_uses_flag_value_when_some() {
|
||||||
|
let mut reader = Cursor::new(Vec::<u8>::new());
|
||||||
|
let got = prompt_or_flag_with_reader::<String, _>(
|
||||||
|
Some("from-flag".to_string()),
|
||||||
|
"Title",
|
||||||
|
|_| panic!("parser must not run when flag is Some"),
|
||||||
|
&mut reader,
|
||||||
|
).expect("flag value path should succeed");
|
||||||
|
assert_eq!(got, "from-flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_prompts_when_none() {
|
||||||
|
let mut reader = Cursor::new(b"prompted\n".to_vec());
|
||||||
|
let got = prompt_or_flag_with_reader::<String, _>(
|
||||||
|
None,
|
||||||
|
"Title",
|
||||||
|
|s| Ok(s.to_string()),
|
||||||
|
&mut reader,
|
||||||
|
).expect("prompt path should succeed");
|
||||||
|
assert_eq!(got, "prompted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_optional_returns_some_from_flag_without_reading() {
|
||||||
|
let mut reader = Cursor::new(Vec::<u8>::new());
|
||||||
|
let got = prompt_or_flag_optional_with_reader::<String, _>(
|
||||||
|
Some("flag-val".to_string()),
|
||||||
|
"URL",
|
||||||
|
|_| panic!("parser must not run when flag is Some"),
|
||||||
|
&mut reader,
|
||||||
|
).expect("flag value path should succeed");
|
||||||
|
assert_eq!(got, Some("flag-val".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_optional_prompts_and_blank_yields_none() {
|
||||||
|
let mut reader = Cursor::new(b"\n".to_vec());
|
||||||
|
let got = prompt_or_flag_optional_with_reader::<String, _>(
|
||||||
|
None,
|
||||||
|
"URL",
|
||||||
|
|_| panic!("parser must not run on blank input"),
|
||||||
|
&mut reader,
|
||||||
|
).expect("blank prompt should succeed with None");
|
||||||
|
assert_eq!(got, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_optional_prompts_and_value_runs_parser() {
|
||||||
|
let mut reader = Cursor::new(b" 42 \n".to_vec());
|
||||||
|
let got = prompt_or_flag_optional_with_reader::<u32, _>(
|
||||||
|
None,
|
||||||
|
"Number",
|
||||||
|
|s| s.parse::<u32>().map_err(Into::into),
|
||||||
|
&mut reader,
|
||||||
|
).expect("value should parse");
|
||||||
|
assert_eq!(got, Some(42));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,9 +69,15 @@ impl UnlockedVault {
|
|||||||
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
|
/// Save the manifest and refresh the plaintext groups.cache. This is the
|
||||||
|
/// canonical "I just mutated the manifest" funnel — every command that
|
||||||
|
/// changes the manifest goes through this method, so cache freshness is
|
||||||
|
/// a compile-time invariant rather than a discipline rule.
|
||||||
|
pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
|
||||||
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||||
atomic_write(&self.manifest_path(), &bytes)
|
atomic_write(&self.manifest_path(), &bytes)?;
|
||||||
|
crate::helpers::refresh_groups_cache(&self.root, manifest);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_settings(&self) -> Result<VaultSettings> {
|
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||||
@@ -107,17 +113,52 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
|||||||
Ok(salt)
|
Ok(salt)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
pub(crate) struct ParamsFile {
|
||||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
pub format_version: u32,
|
||||||
#[derive(serde::Deserialize)]
|
pub kdf: ParamsKdf,
|
||||||
struct ParamsFile {
|
pub aead: String,
|
||||||
kdf: KdfParams,
|
pub salt_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) struct ParamsKdf {
|
||||||
|
pub algorithm: String,
|
||||||
|
pub argon2_m: u32,
|
||||||
|
pub argon2_t: u32,
|
||||||
|
pub argon2_p: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParamsFile {
|
||||||
|
pub fn for_new_vault(params: &KdfParams) -> Self {
|
||||||
|
Self {
|
||||||
|
format_version: 2,
|
||||||
|
kdf: ParamsKdf {
|
||||||
|
algorithm: "argon2id-v0x13".into(),
|
||||||
|
argon2_m: params.argon2_m,
|
||||||
|
argon2_t: params.argon2_t,
|
||||||
|
argon2_p: params.argon2_p,
|
||||||
|
},
|
||||||
|
aead: "xchacha20poly1305".into(),
|
||||||
|
salt_path: ".relicario/salt".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_kdf_params(&self) -> KdfParams {
|
||||||
|
KdfParams {
|
||||||
|
argon2_m: self.kdf.argon2_m,
|
||||||
|
argon2_t: self.kdf.argon2_t,
|
||||||
|
argon2_p: self.kdf.argon2_p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||||
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
.context("failed to read .relicario/params.json")?;
|
.context("failed to read .relicario/params.json")?;
|
||||||
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||||
Ok(pf.kdf)
|
Ok(pf.to_kdf_params())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||||
@@ -149,3 +190,78 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
|||||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FIXTURE: &str = r#"{
|
||||||
|
"format_version": 2,
|
||||||
|
"kdf": {
|
||||||
|
"algorithm": "argon2id-v0x13",
|
||||||
|
"argon2_m": 65536,
|
||||||
|
"argon2_t": 3,
|
||||||
|
"argon2_p": 4
|
||||||
|
},
|
||||||
|
"aead": "xchacha20poly1305",
|
||||||
|
"salt_path": ".relicario/salt"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_file_round_trips_current_layout() {
|
||||||
|
let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
|
||||||
|
assert_eq!(pf.format_version, 2);
|
||||||
|
assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
|
||||||
|
assert_eq!(pf.kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(pf.kdf.argon2_t, 3);
|
||||||
|
assert_eq!(pf.kdf.argon2_p, 4);
|
||||||
|
assert_eq!(pf.aead, "xchacha20poly1305");
|
||||||
|
assert_eq!(pf.salt_path, ".relicario/salt");
|
||||||
|
|
||||||
|
let kdf = pf.to_kdf_params();
|
||||||
|
assert_eq!(kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(kdf.argon2_t, 3);
|
||||||
|
assert_eq!(kdf.argon2_p, 4);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&pf).expect("re-serialize");
|
||||||
|
let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
|
||||||
|
assert_eq!(pf2.format_version, 2);
|
||||||
|
assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
|
||||||
|
assert_eq!(pf2.kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(pf2.kdf.argon2_t, 3);
|
||||||
|
assert_eq!(pf2.kdf.argon2_p, 4);
|
||||||
|
assert_eq!(pf2.aead, "xchacha20poly1305");
|
||||||
|
assert_eq!(pf2.salt_path, ".relicario/salt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_new_vault_produces_expected_shape() {
|
||||||
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
let pf = ParamsFile::for_new_vault(¶ms);
|
||||||
|
let v = serde_json::to_value(&pf).expect("to_value");
|
||||||
|
assert_eq!(v["format_version"], 2);
|
||||||
|
assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
|
||||||
|
assert_eq!(v["kdf"]["argon2_m"], 65536);
|
||||||
|
assert_eq!(v["kdf"]["argon2_t"], 3);
|
||||||
|
assert_eq!(v["kdf"]["argon2_p"], 4);
|
||||||
|
assert_eq!(v["aead"], "xchacha20poly1305");
|
||||||
|
assert_eq!(v["salt_path"], ".relicario/salt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn after_manifest_change_writes_manifest_and_groups_cache() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
std::fs::create_dir_all(root.join(".relicario")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("items")).unwrap();
|
||||||
|
let vault = UnlockedVault {
|
||||||
|
root: root.clone(),
|
||||||
|
master_key: Zeroizing::new([0u8; 32]),
|
||||||
|
};
|
||||||
|
let manifest = Manifest::new();
|
||||||
|
|
||||||
|
vault.after_manifest_change(&manifest).unwrap();
|
||||||
|
assert!(root.join("manifest.enc").exists());
|
||||||
|
assert!(root.join(".relicario/groups.cache").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,6 +109,72 @@ fn rm_restore_purge_cycle() {
|
|||||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_empty_batches_into_one_commit() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Add 3 items.
|
||||||
|
for title in ["alpha", "bravo", "charlie"] {
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", title,
|
||||||
|
"--username", "u",
|
||||||
|
"--password", "p",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add {title} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete all 3.
|
||||||
|
for title in ["alpha", "bravo", "charlie"] {
|
||||||
|
let out = v.run(&["rm", title]);
|
||||||
|
assert!(out.status.success(), "rm {title} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set retention to 0 days so the recently-trashed items become purgeable
|
||||||
|
// (should_purge: now - trashed_at > 0 * 86400 = 0).
|
||||||
|
let out = v.run(&["settings", "trash-retention", "--days", "0"]);
|
||||||
|
assert!(out.status.success(), "settings trash-retention failed");
|
||||||
|
|
||||||
|
// should_purge uses strict > on (now - trashed_at), so equal-second
|
||||||
|
// timestamps don't qualify.
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
|
||||||
|
// Count commits before.
|
||||||
|
let before = std::process::Command::new("git")
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.current_dir(v.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let before_count: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
// Run trash empty.
|
||||||
|
let out = v.run(&["trash", "empty"]);
|
||||||
|
assert!(out.status.success(), "trash empty failed: stderr={}",
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Count commits after.
|
||||||
|
let after = std::process::Command::new("git")
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.current_dir(v.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let after_count: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
after_count - before_count, 1,
|
||||||
|
"trash empty should fire exactly one commit; before={before_count} after={after_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The remaining `list --trashed` should be empty.
|
||||||
|
let out = v.run(&["list", "--trashed"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("alpha") && !stdout.contains("bravo") && !stdout.contains("charlie"),
|
||||||
|
"items still in trashed list: stdout={stdout} stderr={stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generate_random_and_bip39() {
|
fn generate_random_and_bip39() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
@@ -135,3 +201,20 @@ fn generate_random_and_bip39() {
|
|||||||
let phrase = String::from_utf8(out.stdout).unwrap();
|
let phrase = String::from_utf8(out.stdout).unwrap();
|
||||||
assert_eq!(phrase.trim().split(' ').count(), 5);
|
assert_eq!(phrase.trim().split(' ').count(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_card_via_stdin_flags_is_non_interactive() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let out = v.run_with_input(
|
||||||
|
&["add", "card", "--title", "Visa", "--kind", "credit",
|
||||||
|
"--number-stdin", "--cvv-stdin", "--pin-stdin"],
|
||||||
|
&["4111111111111111", "123", "4321"],
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card via stdin failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = v.run(&["get", "Visa"]);
|
||||||
|
assert!(got.status.success(), "get Visa failed: {}", String::from_utf8_lossy(&got.stderr));
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||||
|
assert!(stdout.contains("********"), "card number should be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "card number leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|||||||
216
crates/relicario-cli/tests/org_authz.rs
Normal file
216
crates/relicario-cli/tests/org_authz.rs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
//! Authorization regression tests for the `relicario org` item commands.
|
||||||
|
//!
|
||||||
|
//! These cover two gaps the B9–B14 item-CRUD work left open:
|
||||||
|
//! 1. Grant-DENIAL on the read/mutate-by-query commands (`get`, `edit`, `rm`,
|
||||||
|
//! `restore`, `purge`). Only `add` had a denial test before this. An
|
||||||
|
//! ungranted member must be rejected by EVERY one of them, and `get` must
|
||||||
|
//! not leak the item's plaintext.
|
||||||
|
//! 2. SecureNote body masking on `org get`, mirroring the Login-password
|
||||||
|
//! masking already asserted in `org_items.rs`.
|
||||||
|
//!
|
||||||
|
//! The multi-member harness mirrors `org_lifecycle.rs`'s `Dev` pattern: each
|
||||||
|
//! `Dev` is an isolated XDG config home carrying its own ed25519 device key, so
|
||||||
|
//! a second member can be added with their OWN keypair and then attempt commands
|
||||||
|
//! against the shared vault.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A device home (its own XDG config + ed25519 signing key). One `Dev` is the
|
||||||
|
/// owner; a second `Dev` plays the ungranted member.
|
||||||
|
struct Dev {
|
||||||
|
xdg: PathBuf,
|
||||||
|
_config: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dev {
|
||||||
|
/// Generate an OpenSSH ed25519 signing key for `name` and mark it current.
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let config = TempDir::new().unwrap();
|
||||||
|
let xdg = config.path().to_path_buf();
|
||||||
|
let devices = xdg.join("relicario").join("devices").join(name);
|
||||||
|
std::fs::create_dir_all(&devices).unwrap();
|
||||||
|
let keyfile = devices.join("signing.key");
|
||||||
|
let st = Command::new("ssh-keygen")
|
||||||
|
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||||
|
.arg(&keyfile)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.expect("ssh-keygen");
|
||||||
|
assert!(st.success(), "ssh-keygen failed");
|
||||||
|
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
xdg.join("relicario").join("devices").join("current"),
|
||||||
|
format!("{name}\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Dev { xdg, _config: config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OpenSSH public key string for one of this device's keys.
|
||||||
|
fn pubkey(&self, name: &str) -> String {
|
||||||
|
std::fs::read_to_string(
|
||||||
|
self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `relicario <args>` against `vault` with this device active.
|
||||||
|
fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||||
|
.env("RELICARIO_ORG_DIR", vault)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
cmd.output().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_member_id(vault: &Path) -> String {
|
||||||
|
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||||
|
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a member's id by display name (used to find a freshly added member).
|
||||||
|
fn member_id_by_name(vault: &Path, name: &str) -> String {
|
||||||
|
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||||
|
v["members"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m["display_name"] == name)
|
||||||
|
.unwrap_or_else(|| panic!("member `{name}` not found in members.json"))
|
||||||
|
["member_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
||||||
|
// Owner inits an org, creates `prod`, grants ONLY the owner, and adds an
|
||||||
|
// item into `prod`.
|
||||||
|
let owner_dev = Dev::new("owner-laptop");
|
||||||
|
let vault_tmp = TempDir::new().unwrap();
|
||||||
|
let vault = vault_tmp.path();
|
||||||
|
|
||||||
|
assert!(owner_dev
|
||||||
|
.run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"])
|
||||||
|
.status
|
||||||
|
.success());
|
||||||
|
let owner = owner_member_id(vault);
|
||||||
|
assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||||
|
assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success());
|
||||||
|
assert!(owner_dev
|
||||||
|
.run(vault, &[
|
||||||
|
"org", "add", "login", "--collection", "prod",
|
||||||
|
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
|
||||||
|
])
|
||||||
|
.status
|
||||||
|
.success());
|
||||||
|
|
||||||
|
// A SECOND member joins with their OWN device key but is NOT granted `prod`.
|
||||||
|
let other_dev = Dev::new("other-laptop");
|
||||||
|
let other_pub = other_dev.pubkey("other-laptop");
|
||||||
|
assert!(owner_dev
|
||||||
|
.run(vault, &["org", "add-member", "--key", &other_pub, "--name", "Mallory", "--role", "member"])
|
||||||
|
.status
|
||||||
|
.success());
|
||||||
|
// Sanity: the member exists but holds no collection grants.
|
||||||
|
let mallory = member_id_by_name(vault, "Mallory");
|
||||||
|
assert!(!mallory.is_empty());
|
||||||
|
|
||||||
|
// EVERY read/mutate-by-query command must be rejected for the ungranted
|
||||||
|
// member, and `get` must NOT print the plaintext password.
|
||||||
|
let get = other_dev.run(vault, &["org", "get", "GitHub"]);
|
||||||
|
let get_out = String::from_utf8_lossy(&get.stdout).to_string();
|
||||||
|
let get_err = String::from_utf8_lossy(&get.stderr).to_string();
|
||||||
|
assert!(!get.status.success(), "get must be rejected for ungranted member: {get_out}{get_err}");
|
||||||
|
assert!(!get_out.contains("hunter2"), "get leaked plaintext to ungranted member: {get_out}");
|
||||||
|
assert!(!get_out.contains("alice"), "get leaked username to ungranted member: {get_out}");
|
||||||
|
assert!(
|
||||||
|
get_err.contains("no item matches") || get_err.contains("access denied"),
|
||||||
|
"get error should be denial / not-found: {get_err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// get --show must ALSO be denied and reveal nothing.
|
||||||
|
let get_show = other_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||||
|
assert!(!get_show.status.success(), "get --show must be rejected for ungranted member");
|
||||||
|
assert!(
|
||||||
|
!String::from_utf8_lossy(&get_show.stdout).contains("hunter2"),
|
||||||
|
"get --show leaked plaintext to ungranted member"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (label, args) in [
|
||||||
|
// `org edit` is now interactive (no flat flags); the ungranted member is
|
||||||
|
// rejected at manifest lookup, before any prompt is read.
|
||||||
|
("edit", vec!["org", "edit", "GitHub"]),
|
||||||
|
("rm", vec!["org", "rm", "GitHub"]),
|
||||||
|
("restore", vec!["org", "restore", "GitHub"]),
|
||||||
|
("purge", vec!["org", "purge", "GitHub"]),
|
||||||
|
] {
|
||||||
|
let out = other_dev.run(vault, &args);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"`org {label}` must be rejected for ungranted member; stderr: {stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("no item matches") || stderr.contains("access denied"),
|
||||||
|
"`org {label}` error should be denial / not-found: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The item is untouched: the owner can still read the original password and
|
||||||
|
// username — the ungranted member's get/edit/rm/restore/purge were all denied.
|
||||||
|
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||||
|
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
||||||
|
assert!(owner_get.status.success(), "owner should still read the item");
|
||||||
|
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
||||||
|
assert!(owner_out.contains("alice"), "ungranted member must not have modified the item: {owner_out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_get_masks_secure_note_body_until_show() {
|
||||||
|
let owner_dev = Dev::new("owner-laptop");
|
||||||
|
let vault_tmp = TempDir::new().unwrap();
|
||||||
|
let vault = vault_tmp.path();
|
||||||
|
|
||||||
|
assert!(owner_dev
|
||||||
|
.run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"])
|
||||||
|
.status
|
||||||
|
.success());
|
||||||
|
let owner = owner_member_id(vault);
|
||||||
|
assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||||
|
assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success());
|
||||||
|
assert!(owner_dev
|
||||||
|
.run(vault, &[
|
||||||
|
"org", "add", "secure-note", "--collection", "prod",
|
||||||
|
"--title", "Recovery", "--body", "super-secret-body",
|
||||||
|
])
|
||||||
|
.status
|
||||||
|
.success());
|
||||||
|
|
||||||
|
// Default get masks the body and never prints the plaintext.
|
||||||
|
let masked = owner_dev.run(vault, &["org", "get", "Recovery"]);
|
||||||
|
assert!(masked.status.success(), "get: {}", String::from_utf8_lossy(&masked.stderr));
|
||||||
|
let masked_out = String::from_utf8_lossy(&masked.stdout).to_string();
|
||||||
|
assert!(masked_out.contains("********"), "expected masked body: {masked_out}");
|
||||||
|
assert!(!masked_out.contains("super-secret-body"), "masked get leaked the body: {masked_out}");
|
||||||
|
|
||||||
|
// get --show reveals the body.
|
||||||
|
let shown = owner_dev.run(vault, &["org", "get", "Recovery", "--show"]);
|
||||||
|
assert!(shown.status.success(), "get --show: {}", String::from_utf8_lossy(&shown.stderr));
|
||||||
|
let shown_out = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||||
|
assert!(shown_out.contains("super-secret-body"), "expected plaintext body with --show: {shown_out}");
|
||||||
|
}
|
||||||
24
crates/relicario-cli/tests/org_init.rs
Normal file
24
crates/relicario-cli/tests/org_init.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn run(args: &[&str]) -> std::process::Output {
|
||||||
|
std::process::Command::new(env!("CARGO_BIN_EXE_relicario"))
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("run relicario")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // requires a device key on disk; run manually or via org_init_signing
|
||||||
|
fn org_init_creates_expected_files() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().to_str().unwrap();
|
||||||
|
// `--dir` is a subcommand-scoped global on `org` (B14), so it must come
|
||||||
|
// AFTER `org init`, not before it (matches B10's OrgFixture).
|
||||||
|
let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]);
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
assert!(dir.path().join("org.json").exists());
|
||||||
|
assert!(dir.path().join("members.json").exists());
|
||||||
|
assert!(dir.path().join("collections.json").exists());
|
||||||
|
assert!(dir.path().join("manifest.enc").exists());
|
||||||
|
assert!(dir.path().join(".git").exists());
|
||||||
|
}
|
||||||
149
crates/relicario-cli/tests/org_init_signing.rs
Normal file
149
crates/relicario-cli/tests/org_init_signing.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// Base runner kept as the documented counterpart to relicario_with_git_identity
|
||||||
|
// below (every test in this file needs the git identity, so only the _with_
|
||||||
|
// variant is currently called).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn relicario(config_home: &Path, args: &[&str]) -> std::process::Output {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_relicario"))
|
||||||
|
.env("XDG_CONFIG_HOME", config_home)
|
||||||
|
.env("HOME", config_home) // belt-and-suspenders for dirs on all platforms
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("run relicario")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like relicario() but also injects the git committer identity so that
|
||||||
|
/// `git commit` inside `org init` doesn't fail with "Please tell me who you are."
|
||||||
|
fn relicario_with_git_identity(config_home: &Path, args: &[&str]) -> std::process::Output {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_relicario"))
|
||||||
|
.env("XDG_CONFIG_HOME", config_home)
|
||||||
|
.env("HOME", config_home)
|
||||||
|
.env("GIT_AUTHOR_NAME", "Test Device")
|
||||||
|
.env("GIT_AUTHOR_EMAIL", "test@relicario.test")
|
||||||
|
.env("GIT_COMMITTER_NAME", "Test Device")
|
||||||
|
.env("GIT_COMMITTER_EMAIL", "test@relicario.test")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("run relicario")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(repo: &Path, args: &[&str]) -> std::process::Output {
|
||||||
|
Command::new("git")
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("run git")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay out device keys directly under `<config_home>/relicario/devices/<name>/`
|
||||||
|
/// and set `devices/current` — mirrors the B2 seed_helper_tests approach.
|
||||||
|
/// Returns the OpenSSH public key string so the caller can build an allowed_signers
|
||||||
|
/// file for `git verify-commit`.
|
||||||
|
fn seed_device(config_home: &Path, name: &str) -> String {
|
||||||
|
let (priv_openssh, pub_openssh) =
|
||||||
|
relicario_core::device::generate_keypair().expect("generate_keypair");
|
||||||
|
|
||||||
|
let dev_dir = config_home
|
||||||
|
.join("relicario")
|
||||||
|
.join("devices")
|
||||||
|
.join(name);
|
||||||
|
fs::create_dir_all(&dev_dir).expect("create device dir");
|
||||||
|
let signing_key_path = dev_dir.join("signing.key");
|
||||||
|
fs::write(&signing_key_path, priv_openssh.as_str())
|
||||||
|
.expect("write signing.key");
|
||||||
|
// ssh requires 0600 on private key files or it refuses to use them.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&signing_key_path, fs::Permissions::from_mode(0o600))
|
||||||
|
.expect("chmod signing.key");
|
||||||
|
}
|
||||||
|
fs::write(dev_dir.join("signing.pub"), &pub_openssh)
|
||||||
|
.expect("write signing.pub");
|
||||||
|
// Also write stub deploy key files so configure_git_signing doesn't trip on
|
||||||
|
// a missing deploy.key path (the git config value just points to the file;
|
||||||
|
// the file itself is never read during org init).
|
||||||
|
fs::write(dev_dir.join("deploy.key"), "").expect("write stub deploy.key");
|
||||||
|
fs::write(dev_dir.join("deploy.pub"), "").expect("write stub deploy.pub");
|
||||||
|
|
||||||
|
// Set this device as current.
|
||||||
|
let devices_dir = config_home.join("relicario").join("devices");
|
||||||
|
fs::write(devices_dir.join("current"), format!("{name}\n"))
|
||||||
|
.expect("write current");
|
||||||
|
|
||||||
|
pub_openssh
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_init_produces_a_signed_initial_commit() {
|
||||||
|
let cfg = TempDir::new().unwrap();
|
||||||
|
let org = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Lay out the device key directly (no `device add` needed — it requires Gitea).
|
||||||
|
let pub_openssh = seed_device(cfg.path(), "test-dev");
|
||||||
|
|
||||||
|
// Initialize the org vault. `--dir` comes AFTER `org init` (B14 global).
|
||||||
|
// Inject git identity so the commit doesn't fail "Please tell me who you are."
|
||||||
|
let init = relicario_with_git_identity(
|
||||||
|
cfg.path(),
|
||||||
|
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
init.status.success(),
|
||||||
|
"org init failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&init.stdout),
|
||||||
|
String::from_utf8_lossy(&init.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The org repo must be configured to sign.
|
||||||
|
let cfg_out = git(org.path(), &["config", "commit.gpgsign"]);
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&cfg_out.stdout).trim(),
|
||||||
|
"true",
|
||||||
|
"org repo must have commit.gpgsign=true"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The HEAD commit object must carry a signature header.
|
||||||
|
let head = git(org.path(), &["cat-file", "commit", "HEAD"]);
|
||||||
|
let body = String::from_utf8_lossy(&head.stdout);
|
||||||
|
assert!(
|
||||||
|
body.contains("gpgsig "),
|
||||||
|
"HEAD commit must be signed (no gpgsig header found):\n{body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configure an allowed_signers file so `git verify-commit` can validate the
|
||||||
|
// SSH signature. The principal must match the committer email injected above.
|
||||||
|
let allowed_signers_path = cfg.path().join("allowed_signers");
|
||||||
|
let allowed_line = format!("test@relicario.test {}", pub_openssh.trim());
|
||||||
|
fs::write(&allowed_signers_path, format!("{allowed_line}\n"))
|
||||||
|
.expect("write allowed_signers");
|
||||||
|
git(
|
||||||
|
org.path(),
|
||||||
|
&[
|
||||||
|
"config",
|
||||||
|
"gpg.ssh.allowedSignersFile",
|
||||||
|
allowed_signers_path.to_str().unwrap(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now verify-commit should succeed.
|
||||||
|
let verify = git(org.path(), &["verify-commit", "HEAD"]);
|
||||||
|
assert!(
|
||||||
|
verify.status.success(),
|
||||||
|
"git verify-commit HEAD failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&verify.stdout),
|
||||||
|
String::from_utf8_lossy(&verify.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The commit body must carry the org-init action trailer.
|
||||||
|
let log_out = git(org.path(), &["log", "-1", "--format=%B"]);
|
||||||
|
let commit_body = String::from_utf8_lossy(&log_out.stdout);
|
||||||
|
assert!(
|
||||||
|
commit_body.contains("Relicario-Action: org-init"),
|
||||||
|
"HEAD commit body must contain 'Relicario-Action: org-init' trailer:\n{commit_body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
587
crates/relicario-cli/tests/org_items.rs
Normal file
587
crates/relicario-cli/tests/org_items.rs
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
|
||||||
|
struct OrgFixture {
|
||||||
|
_config: TempDir,
|
||||||
|
vault: TempDir,
|
||||||
|
xdg: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgFixture {
|
||||||
|
/// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
|
||||||
|
/// register it as the current device, then `org init`.
|
||||||
|
fn new() -> Self {
|
||||||
|
let config = TempDir::new().unwrap();
|
||||||
|
let xdg = config.path().to_path_buf();
|
||||||
|
let devices = xdg.join("relicario").join("devices").join("laptop");
|
||||||
|
std::fs::create_dir_all(&devices).unwrap();
|
||||||
|
|
||||||
|
// Generate an OpenSSH ed25519 keypair without a passphrase.
|
||||||
|
let keyfile = devices.join("signing.key");
|
||||||
|
let status = Command::new("ssh-keygen")
|
||||||
|
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||||
|
.arg(&keyfile)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.expect("ssh-keygen");
|
||||||
|
assert!(status.success(), "ssh-keygen failed");
|
||||||
|
// ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
|
||||||
|
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||||
|
// Mark this device current.
|
||||||
|
std::fs::write(
|
||||||
|
xdg.join("relicario").join("devices").join("current"),
|
||||||
|
"laptop\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vault = TempDir::new().unwrap();
|
||||||
|
let f = OrgFixture { _config: config, vault, xdg };
|
||||||
|
|
||||||
|
let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
|
||||||
|
assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vault_path(&self) -> &Path { self.vault.path() }
|
||||||
|
fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }
|
||||||
|
|
||||||
|
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||||
|
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
cmd.output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner member id printed by `org init`/`org status`. We read it from
|
||||||
|
/// members.json directly to avoid parsing stdout.
|
||||||
|
fn owner_member_id(&self) -> String {
|
||||||
|
let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||||
|
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
|
||||||
|
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
|
||||||
|
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
|
||||||
|
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
|
||||||
|
use std::io::Write as _;
|
||||||
|
let mut child = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.env("XDG_CONFIG_HOME", &self.xdg)
|
||||||
|
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create collection `slug` and grant the owner access to it — the common
|
||||||
|
/// setup the item-type round-trip tests share.
|
||||||
|
fn create_collection_and_grant(&self, slug: &str) {
|
||||||
|
let owner = self.owner_member_id();
|
||||||
|
assert!(
|
||||||
|
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
|
||||||
|
"create-collection {slug} failed",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.run(&["org", "grant", &owner, slug]).status.success(),
|
||||||
|
"grant {slug} failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_get_list_round_trip() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
let owner = f.owner_member_id();
|
||||||
|
|
||||||
|
// Create a collection and grant the owner access to it.
|
||||||
|
let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
|
||||||
|
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let out = f.run(&["org", "grant", &owner, "prod"]);
|
||||||
|
assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Add a login into the prod collection.
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "prod",
|
||||||
|
"--title", "GitHub", "--username", "alice",
|
||||||
|
"--url", "https://github.com", "--password", "hunter2",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// The blob must live under items/prod/, NOT flat items/.
|
||||||
|
let prod_dir = f.vault_path().join("items").join("prod");
|
||||||
|
let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
|
||||||
|
assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");
|
||||||
|
|
||||||
|
// list shows it.
|
||||||
|
let out = f.run(&["org", "list"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
||||||
|
|
||||||
|
// get masks by default.
|
||||||
|
let out = f.run(&["org", "get", "GitHub"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("********"), "expected masked secret: {stdout}");
|
||||||
|
assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");
|
||||||
|
|
||||||
|
// get --show reveals.
|
||||||
|
let out = f.run(&["org", "get", "GitHub", "--show"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");
|
||||||
|
|
||||||
|
// The commit trailer records the action + collection + item.
|
||||||
|
let log = Command::new("git")
|
||||||
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
|
assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
|
||||||
|
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||||
|
assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_rejects_ungranted_collection() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
// Create the collection but do NOT grant the owner.
|
||||||
|
let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
|
||||||
|
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "secret",
|
||||||
|
"--title", "X", "--username", "u", "--password", "p",
|
||||||
|
]);
|
||||||
|
assert!(!out.status.success(), "add into ungranted collection must fail");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_rejects_unknown_collection() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "ghost",
|
||||||
|
"--title", "X", "--username", "u", "--password", "p",
|
||||||
|
]);
|
||||||
|
assert!(!out.status.success(), "add into nonexistent collection must fail");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_updates_fields_and_commits_update_trailer() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("prod");
|
||||||
|
assert!(f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "prod",
|
||||||
|
"--title", "Mail", "--username", "old", "--password", "pw",
|
||||||
|
]).status.success());
|
||||||
|
|
||||||
|
// org edit is now interactive per-type: keep title, set username=new-user,
|
||||||
|
// keep URL, decline password change.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
|
||||||
|
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let out = f.run(&["org", "get", "Mail", "--show"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
||||||
|
|
||||||
|
let log = Command::new("git")
|
||||||
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||||
|
.output().unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
|
assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
|
||||||
|
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_rm_restore_purge_cycle() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
let owner = f.owner_member_id();
|
||||||
|
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||||
|
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||||
|
assert!(f.run(&[
|
||||||
|
"org", "add", "secure-note", "--collection", "prod",
|
||||||
|
"--title", "Recovery", "--body", "codes-here",
|
||||||
|
]).status.success());
|
||||||
|
|
||||||
|
// rm → appears only with --trashed.
|
||||||
|
assert!(f.run(&["org", "rm", "Recovery"]).status.success());
|
||||||
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||||
|
assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
|
||||||
|
let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||||
|
assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");
|
||||||
|
|
||||||
|
// restore → back in default list.
|
||||||
|
assert!(f.run(&["org", "restore", "Recovery"]).status.success());
|
||||||
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||||
|
assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");
|
||||||
|
|
||||||
|
// purge → blob gone, entry gone, item-purge trailer.
|
||||||
|
assert!(f.run(&["org", "purge", "Recovery"]).status.success());
|
||||||
|
let prod_dir = f.vault_path().join("items").join("prod");
|
||||||
|
let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
|
||||||
|
assert_eq!(count, 0, "blob not purged from items/prod/");
|
||||||
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||||
|
assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");
|
||||||
|
|
||||||
|
let log = Command::new("git")
|
||||||
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||||
|
.output().unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
|
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
|
||||||
|
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
|
||||||
|
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
|
||||||
|
// must mask every secret unless `--show` is passed — asserted below.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_card_via_stdin_then_get_masks_secret() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// build_card reads number, then cvv, then pin — one line each, in that order.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||||
|
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||||
|
],
|
||||||
|
"4111111111111111\n123\n4321\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// get masks the card number by default.
|
||||||
|
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
|
||||||
|
|
||||||
|
// --show reveals it.
|
||||||
|
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
|
||||||
|
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||||
|
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_key_via_stdin_then_get_masks_material() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// build_key reads key material from stdin to EOF (multiline secret).
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
|
||||||
|
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
|
||||||
|
],
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Deploy Key"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_totp_with_secret_flag_round_trips() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// Totp accepts the base32 secret via --secret (no stdin needed).
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||||
|
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "AWS root"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
|
||||||
|
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_card_interactive_changes_holder() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||||
|
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||||
|
],
|
||||||
|
"4111111111111111\n123\n4321\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Interactive edit: keep title, set holder, decline number change.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
|
||||||
|
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_totp_interactive_changes_issuer() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
assert!(f.run(&[
|
||||||
|
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||||
|
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]).status.success());
|
||||||
|
|
||||||
|
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
|
||||||
|
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "AWS root"]);
|
||||||
|
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
// `secret` exists but is NOT granted to the owner.
|
||||||
|
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
|
||||||
|
|
||||||
|
// ensure_grant runs before any secret prompt in run_add, so these need no
|
||||||
|
// stdin — each new type must be rejected for a collection it lacks a grant for.
|
||||||
|
for args in [
|
||||||
|
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
|
||||||
|
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
|
||||||
|
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||||
|
] {
|
||||||
|
let out = f.run(&args);
|
||||||
|
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(err.contains("access denied") || err.contains("grant"),
|
||||||
|
"expected grant denial for {args:?}: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and rejected for a nonexistent collection.
|
||||||
|
for args in [
|
||||||
|
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
|
||||||
|
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
|
||||||
|
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||||
|
] {
|
||||||
|
let out = f.run(&args);
|
||||||
|
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(err.contains("does not exist") || err.contains("ghost"),
|
||||||
|
"expected unknown-collection error for {args:?}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_secure_note_via_body_stdin_masks_body() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
|
||||||
|
"line one\nsuper-secret-line\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Runbook"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
|
||||||
|
|
||||||
|
let shown = f.run(&["org", "get", "Runbook", "--show"]);
|
||||||
|
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_totp_via_secret_stdin_round_trips() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
// build_totp(secret_stdin=true) reads one base32 line from stdin.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
|
||||||
|
"JBSWY3DPEHPK3PXP\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "VPN"]);
|
||||||
|
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_key_replaces_material_and_reveals_with_show() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
|
||||||
|
"--label", "ci", "--material-stdin"],
|
||||||
|
"OLD-MATERIAL-aaaa\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
|
||||||
|
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
|
||||||
|
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let masked = f.run(&["org", "get", "Signing Key"]);
|
||||||
|
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
|
||||||
|
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
|
||||||
|
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
|
||||||
|
|
||||||
|
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
|
||||||
|
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||||
|
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
|
||||||
|
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8.1 org Document tests -----------------------------------------------
|
||||||
|
|
||||||
|
/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/`
|
||||||
|
/// proves every attachment add/remove was staged into the signed commit.
|
||||||
|
fn git_porcelain(repo: &str) -> String {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.args(["-C", repo, "status", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_document_stores_collection_scoped_attachment() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let src = srcdir.path().join("note.txt");
|
||||||
|
std::fs::write(&src, b"secret memo").unwrap();
|
||||||
|
|
||||||
|
let out = f.run(&["org", "add", "document", "--collection", "eng",
|
||||||
|
"--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
|
||||||
|
assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Encrypted blob at attachments/eng/<item-id>/<att-id>.enc (3 segments).
|
||||||
|
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||||
|
assert!(att_eng.exists(), "attachment dir missing");
|
||||||
|
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||||
|
assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir");
|
||||||
|
let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect();
|
||||||
|
assert_eq!(blobs.len(), 1, "expected exactly one attachment blob");
|
||||||
|
assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc");
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Q3 Memo"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||||
|
assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}");
|
||||||
|
// Staging proof: nothing attachment-related left uncommitted.
|
||||||
|
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_purge_document_removes_attachment_dir() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let src = srcdir.path().join("d.bin");
|
||||||
|
std::fs::write(&src, b"bytes").unwrap();
|
||||||
|
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||||
|
"--title", "Doc", "--file", src.to_str().unwrap()]).status.success());
|
||||||
|
|
||||||
|
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||||
|
assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add");
|
||||||
|
|
||||||
|
assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm");
|
||||||
|
let out = f.run(&["org", "purge", "Doc"]);
|
||||||
|
assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none();
|
||||||
|
assert!(empty, "attachment dir should be gone after purge");
|
||||||
|
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_document_replaces_attachment_and_stages_cleanly() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let a = srcdir.path().join("a.txt");
|
||||||
|
std::fs::write(&a, b"version A").unwrap();
|
||||||
|
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||||
|
"--title", "Spec", "--file", a.to_str().unwrap()]).status.success());
|
||||||
|
|
||||||
|
let b = srcdir.path().join("b.md");
|
||||||
|
std::fs::write(&b, b"version B has different content").unwrap();
|
||||||
|
let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]);
|
||||||
|
assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string();
|
||||||
|
assert!(got.contains("Filename: b.md"), "get should show new filename: {got}");
|
||||||
|
assert!(!got.contains("a.txt"), "old filename should be gone: {got}");
|
||||||
|
|
||||||
|
// Old blob replaced, not accumulated: exactly one blob remains.
|
||||||
|
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||||
|
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||||
|
assert_eq!(item_dirs.len(), 1, "one item attachment dir");
|
||||||
|
let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count();
|
||||||
|
assert_eq!(blobs, 1, "old blob must be replaced, not accumulated");
|
||||||
|
|
||||||
|
// The key staging proof: no orphaned old blob / unstaged new blob.
|
||||||
|
assert!(!git_porcelain(f.vault_str()).contains("attachments/"),
|
||||||
|
"edit-replace left attachment changes unstaged (incomplete git add)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_file_on_non_document_is_rejected() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
assert!(f.run(&["org", "add", "login", "--collection", "eng",
|
||||||
|
"--title", "Site", "--password", "p"]).status.success());
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let x = srcdir.path().join("x.txt");
|
||||||
|
std::fs::write(&x, b"nope").unwrap();
|
||||||
|
|
||||||
|
let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success(), "--file on a Login must be rejected");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_document_into_ungranted_collection_is_denied() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
// Collection exists but the owner is NOT granted.
|
||||||
|
assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(),
|
||||||
|
"create-collection");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let src = srcdir.path().join("f.txt");
|
||||||
|
std::fs::write(&src, b"data").unwrap();
|
||||||
|
|
||||||
|
let out = f.run(&["org", "add", "document", "--collection", "secret",
|
||||||
|
"--title", "X", "--file", src.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success(), "ungranted document add must fail");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||||
|
// Grant is enforced before any attachment is written.
|
||||||
|
assert!(!f.vault_path().join("attachments").join("secret").exists(),
|
||||||
|
"no attachment dir should exist on a denied add");
|
||||||
|
}
|
||||||
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A device home + an org vault. A second device can be wired for multi-member.
|
||||||
|
struct Dev {
|
||||||
|
xdg: PathBuf,
|
||||||
|
_config: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dev {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let config = TempDir::new().unwrap();
|
||||||
|
let xdg = config.path().to_path_buf();
|
||||||
|
let devices = xdg.join("relicario").join("devices").join(name);
|
||||||
|
std::fs::create_dir_all(&devices).unwrap();
|
||||||
|
let keyfile = devices.join("signing.key");
|
||||||
|
let st = Command::new("ssh-keygen")
|
||||||
|
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||||
|
.arg(&keyfile)
|
||||||
|
.stdout(Stdio::null()).stderr(Stdio::null())
|
||||||
|
.status().expect("ssh-keygen");
|
||||||
|
assert!(st.success());
|
||||||
|
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||||
|
std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap();
|
||||||
|
Dev { xdg, _config: config }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pubkey(&self, name: &str) -> String {
|
||||||
|
std::fs::read_to_string(
|
||||||
|
self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
|
||||||
|
).unwrap().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||||
|
.env("RELICARIO_ORG_DIR", vault)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
cmd.output().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_member_id(vault: &Path) -> String {
|
||||||
|
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||||
|
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up an org with the owner granted `prod` and one login item in it.
|
||||||
|
fn setup_with_item() -> (Dev, TempDir, String) {
|
||||||
|
let dev = Dev::new("laptop");
|
||||||
|
let vault = TempDir::new().unwrap();
|
||||||
|
let v = vault.path();
|
||||||
|
assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success());
|
||||||
|
let owner = owner_member_id(v);
|
||||||
|
assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||||
|
assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success());
|
||||||
|
assert!(dev.run(v, &[
|
||||||
|
"org", "add", "login", "--collection", "prod",
|
||||||
|
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
|
||||||
|
]).status.success());
|
||||||
|
(dev, vault, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) audit --format json parses + has expected actions.
|
||||||
|
#[test]
|
||||||
|
fn audit_format_json_is_valid_and_has_actions() {
|
||||||
|
let (dev, vault, _owner) = setup_with_item();
|
||||||
|
let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]);
|
||||||
|
assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse");
|
||||||
|
let arr = events.as_array().expect("array");
|
||||||
|
let actions: Vec<&str> = arr.iter()
|
||||||
|
.filter_map(|e| e["action"].as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(actions.contains(&"org-init"), "actions: {actions:?}");
|
||||||
|
assert!(actions.contains(&"collection-create"), "actions: {actions:?}");
|
||||||
|
assert!(actions.contains(&"item-create"), "actions: {actions:?}");
|
||||||
|
// Honest signer attribution: none of these should be TAMPERED (signer == trailer).
|
||||||
|
assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) a forged-trailer commit is flagged TAMPERED.
|
||||||
|
#[test]
|
||||||
|
fn forged_trailer_commit_is_flagged_tampered() {
|
||||||
|
let (dev, vault, owner) = setup_with_item();
|
||||||
|
let v = vault.path();
|
||||||
|
|
||||||
|
// Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than
|
||||||
|
// the real signer. We reuse the org repo's own signing config (set by
|
||||||
|
// `org init`), so the commit verifies — but the trailer lies.
|
||||||
|
std::fs::write(v.join("decoy.txt"), "x").unwrap();
|
||||||
|
let git = |args: &[&str]| {
|
||||||
|
Command::new("git").current_dir(v).args(args)
|
||||||
|
.env("XDG_CONFIG_HOME", &dev.xdg)
|
||||||
|
.output().unwrap()
|
||||||
|
};
|
||||||
|
assert!(git(&["add", "decoy.txt"]).status.success());
|
||||||
|
let forged_msg = format!(
|
||||||
|
"forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}"
|
||||||
|
);
|
||||||
|
// commit -S uses the repo's configured signing key (the real owner key).
|
||||||
|
let c = git(&["commit", "-S", "-m", &forged_msg]);
|
||||||
|
assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr));
|
||||||
|
|
||||||
|
let out = dev.run(v, &["org", "audit", "--format", "json"]);
|
||||||
|
let events: serde_json::Value =
|
||||||
|
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
|
||||||
|
let forged = events.as_array().unwrap().iter()
|
||||||
|
.find(|e| e["action"] == "item-update")
|
||||||
|
.expect("forged item-update event present");
|
||||||
|
// Trailer claims ffff... but the verified signer is the owner → TAMPERED.
|
||||||
|
assert_eq!(forged["tampered"], serde_json::Value::Bool(true));
|
||||||
|
assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) concurrent rotate-key aborts with the exact spec error string.
|
||||||
|
#[test]
|
||||||
|
fn concurrent_rotate_key_aborts_with_spec_string() {
|
||||||
|
let (dev, vault, _owner) = setup_with_item();
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
let v = vault.path();
|
||||||
|
let git = |args: &[&str]| Command::new("git").current_dir(v).args(args)
|
||||||
|
.env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap();
|
||||||
|
|
||||||
|
// Make a bare origin and push, so a divergent upstream can be simulated.
|
||||||
|
assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()])
|
||||||
|
.output().unwrap().status.success());
|
||||||
|
assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success());
|
||||||
|
assert!(git(&["push", "-u", "origin", "HEAD"]).status.success());
|
||||||
|
|
||||||
|
// Diverge upstream: a second clone commits + pushes, writing to a SHARED file
|
||||||
|
// so that `git pull --rebase` will hit a merge conflict (add/add or edit/edit)
|
||||||
|
// and exit non-zero — which is how run_rotate_key detects a concurrent rotation.
|
||||||
|
let clone2 = TempDir::new().unwrap();
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()])
|
||||||
|
.output().unwrap().status.success());
|
||||||
|
std::fs::write(clone2.path().join("conflict.txt"), "upstream-version").unwrap();
|
||||||
|
for a in [&["add", "conflict.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] {
|
||||||
|
let _ = Command::new("git").current_dir(clone2.path()).args(a).output();
|
||||||
|
}
|
||||||
|
// Local also writes conflict.txt with different content → add/add conflict on pull.
|
||||||
|
std::fs::write(v.join("conflict.txt"), "local-version").unwrap();
|
||||||
|
assert!(git(&["add", "conflict.txt"]).status.success());
|
||||||
|
assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success());
|
||||||
|
|
||||||
|
let out = dev.run(v, &["org", "rotate-key"]);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation");
|
||||||
|
assert!(
|
||||||
|
stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."),
|
||||||
|
"missing spec error string: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can.
|
||||||
|
#[test]
|
||||||
|
fn removed_member_clone_cannot_decrypt_after_rotation() {
|
||||||
|
// Owner laptop sets up the org + a second member "bob".
|
||||||
|
let (owner_dev, vault, _owner) = setup_with_item();
|
||||||
|
let v = vault.path();
|
||||||
|
let bob = Dev::new("bob-laptop");
|
||||||
|
let bob_pub = bob.pubkey("bob-laptop");
|
||||||
|
|
||||||
|
// Owner adds Bob and grants him prod.
|
||||||
|
assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success());
|
||||||
|
let members = std::fs::read_to_string(v.join("members.json")).unwrap();
|
||||||
|
let mv: serde_json::Value = serde_json::from_str(&members).unwrap();
|
||||||
|
let bob_id = mv["members"].as_array().unwrap().iter()
|
||||||
|
.find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string();
|
||||||
|
assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success());
|
||||||
|
|
||||||
|
// Bob clones the vault dir (his device, his key blob is present).
|
||||||
|
// `cp -r /vault /dst/` places contents at `/dst/<vault_basename>/` — use that
|
||||||
|
// sub-path, not the TempDir root, as the vault for Bob's commands.
|
||||||
|
let bob_clone = TempDir::new().unwrap();
|
||||||
|
let vault_basename = v.file_name().unwrap();
|
||||||
|
let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap();
|
||||||
|
assert!(cp.status.success());
|
||||||
|
let bob_vault = bob_clone.path().join(vault_basename);
|
||||||
|
// Bob can read the item BEFORE removal.
|
||||||
|
let pre = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||||
|
assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal");
|
||||||
|
|
||||||
|
// Owner removes Bob and rotates the key in the live vault.
|
||||||
|
assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success());
|
||||||
|
assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success());
|
||||||
|
|
||||||
|
// Owner (remaining member) can still decrypt in the live vault.
|
||||||
|
let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]);
|
||||||
|
assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read");
|
||||||
|
|
||||||
|
// Copy the rotated item + manifest into Bob's stale clone (simulating a
|
||||||
|
// pull) — his OLD key blob can no longer unwrap the rotated org key.
|
||||||
|
let _ = Command::new("cp").args(["-r",
|
||||||
|
v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output();
|
||||||
|
let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc"));
|
||||||
|
let post = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||||
|
assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"),
|
||||||
|
"removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout));
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Architecture: relicario-core
|
# 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
|
## What this crate is for
|
||||||
|
|
||||||
`relicario-core` is the platform-agnostic cryptographic and data-model heart of the
|
`relicario-core` is the platform-agnostic cryptographic and data-model heart of the
|
||||||
@@ -101,6 +103,58 @@ Pipeline" and "Crate Layout").
|
|||||||
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
||||||
Quantization Index Modulation, and crop-recovery extractor. No other module
|
Quantization Index Modulation, and crop-recovery extractor. No other module
|
||||||
imports it; it is consumed only via the public re-export from `lib.rs`.
|
imports it; it is consumed only via the public re-export from `lib.rs`.
|
||||||
|
- **`org.rs`** — Org-vault data model and ECIES key-wrapping layer
|
||||||
|
(`crates/relicario-core/src/org.rs`). Types: `OrgId` (L15), `MemberId`
|
||||||
|
(L19; `is_valid` L41 — 16 lowercase hex), `OrgRole` (L54;
|
||||||
|
`can_manage_members` L61 = Owner | Admin, `can_manage_owners` L64 = Owner
|
||||||
|
only), `OrgMember` (L72; carries `ed25519_pubkey` in OpenSSH wire format,
|
||||||
|
`collections` grant list, `role`), `OrgMembers` (L86; `schema_version: 1`
|
||||||
|
L93; `validate` L104), `CollectionDef` (L123), `OrgCollections` (L131;
|
||||||
|
`schema_version: 1` L138; `validate` L145 rejects empty / `/` / `.` slugs),
|
||||||
|
`OrgMeta` (L164; `schema_version: 1` L174), `OrgManifestEntry` (L185;
|
||||||
|
carries `collection` slug plus id/type/title/tags/modified/trashed\_at),
|
||||||
|
`OrgManifest` (L199; `schema_version: 1` L206; `filter_for_member` L210
|
||||||
|
returns only entries whose collection slug appears in the member's grants).
|
||||||
|
All four JSON containers carry `schema_version: 1` — distinct from the
|
||||||
|
personal `Manifest` whose `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`).
|
||||||
|
Crypto: `generate_org_key` (L230) → `Zeroizing<[u8;32]>` (256-bit
|
||||||
|
CSPRNG org master key); `wrap_org_key` (L265) / `unwrap_org_key` (L299) —
|
||||||
|
ECIES over X25519, described in detail under **Invariants & contracts**
|
||||||
|
below. `vault.rs` adds `encrypt_org_manifest` / `decrypt_org_manifest` typed
|
||||||
|
wrappers (JSON-serialize → `crypto::encrypt` under the org key, plaintext in
|
||||||
|
`Zeroizing`) consistent with the personal-vault pattern.
|
||||||
|
- **`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
|
## Invariants & contracts
|
||||||
|
|
||||||
@@ -196,6 +250,28 @@ Pipeline" and "Crate Layout").
|
|||||||
also used to derive the key for *unlock*, not just create).
|
also used to derive the key for *unlock*, not just create).
|
||||||
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
||||||
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
|
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
|
||||||
|
- **ECIES wrap-blob layout is fixed** at
|
||||||
|
`ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
|
||||||
|
(`org.rs:264`). The `version(1)` byte is the same `VERSION_BYTE = 0x02`
|
||||||
|
emitted by `crypto::encrypt`, which is what occupies that slot — the layout
|
||||||
|
merely names the regions for clarity.
|
||||||
|
- **KDF wrap key = `SHA-256(dh_shared || ephemeral_pk || recipient_pk)`**
|
||||||
|
(`org.rs:278-281`). The concatenation order is identical in `wrap_org_key`
|
||||||
|
and `unwrap_org_key`; a mismatch in either direction would produce a
|
||||||
|
different key and fail the AEAD open. The intermediate `kdf_input` buffer is
|
||||||
|
held in `Zeroizing<Vec<u8>>`; `org_key`, `wrap_key`, and the decrypted
|
||||||
|
`plaintext` from unwrap are also held in `Zeroizing`.
|
||||||
|
- **ed25519 → X25519 conversion** applies `SHA-512(seed)[..32]` then the
|
||||||
|
RFC 7748 scalar clamp
|
||||||
|
(`scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64`) to derive the
|
||||||
|
private X25519 scalar (`org.rs:242`); the recipient public key is obtained
|
||||||
|
via `ed25519_dalek`'s `to_montgomery()`. This lets device ed25519 keys serve
|
||||||
|
double duty as X25519 recipients without storing a separate DH key.
|
||||||
|
- **Org crypto bypasses Argon2id.** The ECIES inner cipher delegates to
|
||||||
|
`crate::crypto::encrypt` / `decrypt` (XChaCha20-Poly1305, random 24-byte
|
||||||
|
nonce, `VERSION_BYTE = 0x02`) — no AEAD re-implementation. The X25519 KDF
|
||||||
|
output is used directly as the AEAD key; the Argon2id path in `crypto.rs`
|
||||||
|
is not invoked for org key wrapping.
|
||||||
|
|
||||||
## Key flows
|
## Key flows
|
||||||
|
|
||||||
@@ -281,6 +357,35 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
|||||||
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
||||||
when they want to enforce the policy.
|
when they want to enforce the policy.
|
||||||
|
|
||||||
|
### Org key wrap / unwrap
|
||||||
|
|
||||||
|
1. **Wrap** (`org.rs:265`): caller supplies a recipient's OpenSSH ed25519
|
||||||
|
public key string.
|
||||||
|
- Parse the OpenSSH wire format via `ssh-key` to recover the raw 32-byte
|
||||||
|
ed25519 public key bytes; apply `to_montgomery()` (ed25519-dalek) to
|
||||||
|
obtain the recipient's X25519 public key.
|
||||||
|
- Generate an ephemeral X25519 keypair from `OsRng`.
|
||||||
|
- `dh_shared = ephemeral_secret × recipient_x25519_pk` (X25519 DH).
|
||||||
|
- `wrap_key = SHA-256(dh_shared || ephemeral_pk || recipient_pk)`
|
||||||
|
(`org.rs:278-281`), intermediates in `Zeroizing`.
|
||||||
|
- `ct = crate::crypto::encrypt(&wrap_key, &org_key)` — yields the standard
|
||||||
|
`version(1) || nonce(24) || ciphertext+tag` blob.
|
||||||
|
- Return `ephemeral_x25519_pk(32) || ct` (`org.rs:264`).
|
||||||
|
2. **Unwrap** (`org.rs:299`): caller supplies the device ed25519 seed bytes
|
||||||
|
(from `current_device_seed` in the CLI layer, not from `relicario-core`).
|
||||||
|
- Derive X25519 private scalar from seed: `SHA-512(seed)[..32]` + RFC 7748
|
||||||
|
clamp (`org.rs:242`).
|
||||||
|
- Slice the first 32 bytes of the blob as `ephemeral_pk`; read recipient's
|
||||||
|
own X25519 public key via the same `to_montgomery()` path.
|
||||||
|
- `dh_shared = device_x25519_secret × ephemeral_pk`.
|
||||||
|
- Reconstruct `wrap_key` identically; `crypto::decrypt` recovers `org_key`
|
||||||
|
into `Zeroizing`.
|
||||||
|
|
||||||
|
Integration tests: `crates/relicario-core/tests/org.rs` (5 acceptance tests
|
||||||
|
covering wrap/unwrap round-trip, revoked-after-rotation, and manifest
|
||||||
|
`filter_for_member`). A pinned RFC 8032 ed25519→X25519 known-answer vector
|
||||||
|
lives in the `#[cfg(test)]` block inside `org.rs` itself.
|
||||||
|
|
||||||
### imgsecret embed
|
### imgsecret embed
|
||||||
|
|
||||||
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
||||||
@@ -386,11 +491,11 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
|||||||
`generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
|
`generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
|
||||||
inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
|
inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
|
||||||
secret; production code is `OsRng` only.
|
secret; production code is `OsRng` only.
|
||||||
- **`ed25519-dalek` is a dependency placeholder.** Listed in
|
- **`ed25519-dalek` is consumed by `device.rs`.** Together with `ssh-key` (for
|
||||||
`Cargo.toml:17` but unused in `src/`. It exists for the future
|
OpenSSH wire encoding) it backs `generate_keypair`, `sign`, and `verify` —
|
||||||
device-key surface (`RelicarioError::DeviceKey` is the reserved variant,
|
the same primitives the CLI uses to populate `.relicario/devices.json` and
|
||||||
`error.rs:84-88`); device-key signing currently happens in
|
the server uses to verify pre-receive commit signatures. The corresponding
|
||||||
`relicario-cli` instead.
|
error variant is `RelicarioError::DeviceKey`.
|
||||||
|
|
||||||
## Test architecture
|
## Test architecture
|
||||||
|
|
||||||
@@ -512,3 +617,7 @@ round-trip, and the oversized-image-header rejection path.
|
|||||||
source in this crate** (`time.rs:6-8`). Tests that need determinism pass an
|
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
|
explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they
|
||||||
do not stub `now_unix`.
|
do not stub `now_unix`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next:** [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.5.0"
|
version = "0.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Core library for relicario password manager"
|
description = "Core library for relicario password manager"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -15,6 +16,7 @@ sha2 = "0.10"
|
|||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
|
|
||||||
|
|||||||
132
crates/relicario-core/src/base32.rs
Normal file
132
crates/relicario-core/src/base32.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! RFC 4648 base32 codec, no-padding form, lenient on input.
|
||||||
|
//!
|
||||||
|
//! The encoder produces canonical no-padding RFC 4648 output (uppercase ASCII).
|
||||||
|
//! The decoder is lenient: case-insensitive, optional `=` padding, whitespace
|
||||||
|
//! anywhere is stripped before decoding.
|
||||||
|
//!
|
||||||
|
//! Steam Guard's authenticator uses a different (de-ambiguated) alphabet —
|
||||||
|
//! see `crate::item_types::totp::STEAM_ALPHABET`. That codec is intentionally
|
||||||
|
//! NOT routed through this module.
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
/// RFC 4648 base32 encoder, no-padding form. Output is uppercase ASCII.
|
||||||
|
pub fn encode_rfc4648(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for &b in bytes {
|
||||||
|
buffer = (buffer << 8) | (b as u32);
|
||||||
|
bits += 8;
|
||||||
|
while bits >= 5 {
|
||||||
|
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
|
||||||
|
out.push(ALPHA[idx] as char);
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bits > 0 {
|
||||||
|
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
|
||||||
|
out.push(ALPHA[idx] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 4648 base32 decoder, lenient on input.
|
||||||
|
///
|
||||||
|
/// Accepts upper- or lower-case letters, optional `=` padding, and whitespace
|
||||||
|
/// anywhere. Trailing bits less than a full byte are silently discarded
|
||||||
|
/// (canonical RFC 4648 decode).
|
||||||
|
pub fn decode_rfc4648_lenient(s: &str) -> Result<Vec<u8>> {
|
||||||
|
let cleaned: String = s
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
let trimmed = cleaned.trim_end_matches('=');
|
||||||
|
let mut out: Vec<u8> = Vec::with_capacity(trimmed.len() * 5 / 8);
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for ch in trimmed.bytes() {
|
||||||
|
let idx = ALPHA.iter().position(|&a| a == ch).ok_or_else(|| {
|
||||||
|
RelicarioError::InvalidBase32(format!("non-alphabet character {:?}", ch as char))
|
||||||
|
})?;
|
||||||
|
buffer = (buffer << 5) | (idx as u32);
|
||||||
|
bits += 5;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push(((buffer >> bits) & 0xff) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_rfc4648_matches_rfc_test_vectors() {
|
||||||
|
// RFC 4648 §10 test vectors, no-padding form.
|
||||||
|
assert_eq!(encode_rfc4648(b""), "");
|
||||||
|
assert_eq!(encode_rfc4648(b"f"), "MY");
|
||||||
|
assert_eq!(encode_rfc4648(b"fo"), "MZXQ");
|
||||||
|
assert_eq!(encode_rfc4648(b"foo"), "MZXW6");
|
||||||
|
assert_eq!(encode_rfc4648(b"foob"), "MZXW6YQ");
|
||||||
|
assert_eq!(encode_rfc4648(b"fooba"), "MZXW6YTB");
|
||||||
|
assert_eq!(encode_rfc4648(b"foobar"), "MZXW6YTBOI");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rfc4648_lenient_inverts_encoder_on_known_vectors() {
|
||||||
|
let cases: &[(&str, &[u8])] = &[
|
||||||
|
("", b""),
|
||||||
|
("MY", b"f"),
|
||||||
|
("MZXQ", b"fo"),
|
||||||
|
("MZXW6", b"foo"),
|
||||||
|
("MZXW6YQ", b"foob"),
|
||||||
|
("MZXW6YTB", b"fooba"),
|
||||||
|
("MZXW6YTBOI", b"foobar"),
|
||||||
|
];
|
||||||
|
for (s, want) in cases {
|
||||||
|
assert_eq!(&decode_rfc4648_lenient(s).unwrap()[..], *want);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rfc4648_lenient_accepts_lowercase_and_mixed_case() {
|
||||||
|
assert_eq!(decode_rfc4648_lenient("mzxw6").unwrap(), b"foo");
|
||||||
|
assert_eq!(decode_rfc4648_lenient("MzXw6yTbOi").unwrap(), b"foobar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rfc4648_lenient_strips_optional_padding() {
|
||||||
|
assert_eq!(decode_rfc4648_lenient("MY======").unwrap(), b"f");
|
||||||
|
assert_eq!(decode_rfc4648_lenient("MZXW6===").unwrap(), b"foo");
|
||||||
|
assert_eq!(decode_rfc4648_lenient("MZXW6YTBOI======").unwrap(), b"foobar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rfc4648_lenient_strips_whitespace_anywhere() {
|
||||||
|
assert_eq!(decode_rfc4648_lenient(" MZXW 6YTB OI ").unwrap(), b"foobar");
|
||||||
|
assert_eq!(decode_rfc4648_lenient("MZXW\n6YTB\tOI").unwrap(), b"foobar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rfc4648_lenient_rejects_non_alphabet_chars() {
|
||||||
|
assert!(matches!(
|
||||||
|
decode_rfc4648_lenient("MY1"),
|
||||||
|
Err(RelicarioError::InvalidBase32(_))
|
||||||
|
));
|
||||||
|
assert!(decode_rfc4648_lenient("???").is_err());
|
||||||
|
assert!(decode_rfc4648_lenient("MZ!XW").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_decode_round_trips_arbitrary_bytes() {
|
||||||
|
let bytes: Vec<u8> = (0u8..=255).collect();
|
||||||
|
let encoded = encode_rfc4648(&bytes);
|
||||||
|
assert_eq!(decode_rfc4648_lenient(&encoded).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,17 @@ pub enum RelicarioError {
|
|||||||
/// Recovery QR generation or parsing failed.
|
/// Recovery QR generation or parsing failed.
|
||||||
#[error("recovery QR: {0}")]
|
#[error("recovery QR: {0}")]
|
||||||
RecoveryQr(String),
|
RecoveryQr(String),
|
||||||
|
|
||||||
|
/// Base32 decoding failed (non-alphabet character or other malformed
|
||||||
|
/// input). Emitted by [`crate::base32::decode_rfc4648_lenient`] and any
|
||||||
|
/// typed wrappers that delegate to it.
|
||||||
|
#[error("invalid base32: {0}")]
|
||||||
|
InvalidBase32(String),
|
||||||
|
|
||||||
|
/// Card-expiry month/year string failed to parse. Emitted by
|
||||||
|
/// [`crate::time::MonthYear::parse`].
|
||||||
|
#[error("invalid month/year: {0}")]
|
||||||
|
InvalidMonthYear(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ fn map_row(
|
|||||||
let totp = if totp_raw.is_empty() {
|
let totp = if totp_raw.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
match decode_base32_totp(totp_raw) {
|
match crate::base32::decode_rfc4648_lenient(totp_raw) {
|
||||||
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
Ok(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
||||||
secret: Zeroizing::new(bytes),
|
secret: Zeroizing::new(bytes),
|
||||||
algorithm: crate::item_types::TotpAlgorithm::Sha1,
|
algorithm: crate::item_types::TotpAlgorithm::Sha1,
|
||||||
digits: 6,
|
digits: 6,
|
||||||
@@ -196,25 +196,3 @@ fn map_row(
|
|||||||
(Some(item), warning)
|
(Some(item), warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive,
|
|
||||||
/// padding optional. Returns None if the input contains any non-alphabet
|
|
||||||
/// character (after upper-casing). Used by the LastPass importer.
|
|
||||||
fn decode_base32_totp(secret: &str) -> Option<Vec<u8>> {
|
|
||||||
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
||||||
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
|
|
||||||
if upper.is_empty() { return None; }
|
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
|
|
||||||
let mut buffer: u32 = 0;
|
|
||||||
let mut bits: u32 = 0;
|
|
||||||
for ch in upper.bytes() {
|
|
||||||
let idx = ALPHA.iter().position(|&a| a == ch)?;
|
|
||||||
buffer = (buffer << 5) | (idx as u32);
|
|
||||||
bits += 5;
|
|
||||||
if bits >= 8 {
|
|
||||||
bits -= 8;
|
|
||||||
out.push(((buffer >> bits) & 0xFF) as u8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
|||||||
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
|
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
|
||||||
FieldValue::Totp(cfg) => {
|
FieldValue::Totp(cfg) => {
|
||||||
// Store the base32-encoded secret string for human-recognizability.
|
// Store the base32-encoded secret string for human-recognizability.
|
||||||
let s = base32_encode(&cfg.secret);
|
let s = crate::base32::encode_rfc4648(&cfg.secret);
|
||||||
Zeroizing::new(s)
|
Zeroizing::new(s)
|
||||||
}
|
}
|
||||||
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
|
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
|
||||||
@@ -252,28 +252,6 @@ fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
|||||||
Ok(s)
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
|
|
||||||
fn base32_encode(bytes: &[u8]) -> String {
|
|
||||||
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
||||||
let mut out = String::new();
|
|
||||||
let mut buffer: u32 = 0;
|
|
||||||
let mut bits: u32 = 0;
|
|
||||||
for &b in bytes {
|
|
||||||
buffer = (buffer << 8) | (b as u32);
|
|
||||||
bits += 8;
|
|
||||||
while bits >= 5 {
|
|
||||||
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
|
|
||||||
out.push(ALPHA[idx] as char);
|
|
||||||
bits -= 5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bits > 0 {
|
|
||||||
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
|
|
||||||
out.push(ALPHA[idx] as char);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use crate::error::{RelicarioError, Result};
|
|||||||
|
|
||||||
/// Steam Mobile Authenticator's 5-character output alphabet.
|
/// Steam Mobile Authenticator's 5-character output alphabet.
|
||||||
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
||||||
|
///
|
||||||
|
/// Not RFC 4648 — Steam Guard's de-ambiguated alphabet; see [`crate::base32`]
|
||||||
|
/// for the standard implementation.
|
||||||
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -21,6 +24,14 @@ pub struct TotpCore {
|
|||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TotpConfig {
|
||||||
|
/// Decode a base32-encoded TOTP secret (RFC 4648, lenient input) into the
|
||||||
|
/// canonical `Zeroizing<Vec<u8>>` form used in [`Self::secret`].
|
||||||
|
pub fn parse_secret(s: &str) -> Result<Zeroizing<Vec<u8>>> {
|
||||||
|
Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TotpConfig {
|
pub struct TotpConfig {
|
||||||
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
||||||
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
||||||
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
||||||
|
//! - [`base32`] — RFC 4648 base32 codec used for TOTP secret encode/decode.
|
||||||
|
//! - [`mime`] — Filename-extension → MIME-type guess for attachment storage.
|
||||||
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
||||||
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
||||||
//! `ItemCore`/`ItemType` enums.
|
//! `ItemCore`/`ItemType` enums.
|
||||||
@@ -46,6 +48,10 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
|||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||||
|
|
||||||
|
pub mod base32;
|
||||||
|
|
||||||
|
pub mod mime;
|
||||||
|
|
||||||
pub mod time;
|
pub mod time;
|
||||||
pub use time::{now_unix, MonthYear};
|
pub use time::{now_unix, MonthYear};
|
||||||
|
|
||||||
@@ -72,8 +78,8 @@ pub use generators::{generate_passphrase, generate_password, rate_passphrase, va
|
|||||||
|
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
pub use vault::{
|
pub use vault::{
|
||||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings,
|
||||||
encrypt_item, encrypt_manifest, encrypt_settings,
|
encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod imgsecret;
|
pub mod imgsecret;
|
||||||
@@ -87,6 +93,13 @@ pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||||
|
|
||||||
|
pub mod org;
|
||||||
|
pub use org::{
|
||||||
|
generate_org_key, unwrap_org_key, wrap_org_key,
|
||||||
|
CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest,
|
||||||
|
OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole,
|
||||||
|
};
|
||||||
|
|
||||||
pub mod tar_safe;
|
pub mod tar_safe;
|
||||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||||
|
|
||||||
|
|||||||
49
crates/relicario-core/src/mime.rs
Normal file
49
crates/relicario-core/src/mime.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//! Tiny extension → MIME map for the small set of file types Relicario
|
||||||
|
//! attaches today. Unknown extensions fall back to `application/octet-stream`.
|
||||||
|
|
||||||
|
/// Guess a MIME type from a filename's extension. Case-insensitive.
|
||||||
|
pub fn guess_for_extension(filename: &str) -> &'static str {
|
||||||
|
let lower = filename.to_ascii_lowercase();
|
||||||
|
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"txt" => "text/plain",
|
||||||
|
"json" => "application/json",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_extensions_match() {
|
||||||
|
assert_eq!(guess_for_extension("doc.pdf"), "application/pdf");
|
||||||
|
assert_eq!(guess_for_extension("photo.png"), "image/png");
|
||||||
|
assert_eq!(guess_for_extension("photo.jpg"), "image/jpeg");
|
||||||
|
assert_eq!(guess_for_extension("photo.jpeg"), "image/jpeg");
|
||||||
|
assert_eq!(guess_for_extension("notes.txt"), "text/plain");
|
||||||
|
assert_eq!(guess_for_extension("data.json"), "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extension_match_is_case_insensitive() {
|
||||||
|
assert_eq!(guess_for_extension("doc.PDF"), "application/pdf");
|
||||||
|
assert_eq!(guess_for_extension("photo.JPEG"), "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_or_missing_extension_falls_back() {
|
||||||
|
assert_eq!(guess_for_extension("unknown.xyz"), "application/octet-stream");
|
||||||
|
assert_eq!(guess_for_extension("noextension"), "application/octet-stream");
|
||||||
|
assert_eq!(guess_for_extension(""), "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_extension_after_last_dot() {
|
||||||
|
assert_eq!(guess_for_extension("path/to/file.pdf"), "application/pdf");
|
||||||
|
assert_eq!(guess_for_extension("archive.tar.gz"), "application/octet-stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
494
crates/relicario-core/src/org.rs
Normal file
494
crates/relicario-core/src/org.rs
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
|
||||||
|
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
use crate::ids::ItemId;
|
||||||
|
use crate::item_types::ItemType;
|
||||||
|
|
||||||
|
// ── IDs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct OrgId(pub String);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct MemberId(pub String);
|
||||||
|
|
||||||
|
impl OrgId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemberId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemberId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum OrgRole {
|
||||||
|
Owner,
|
||||||
|
Admin,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgRole {
|
||||||
|
pub fn can_manage_members(&self) -> bool {
|
||||||
|
matches!(self, OrgRole::Owner | OrgRole::Admin)
|
||||||
|
}
|
||||||
|
pub fn can_manage_owners(&self) -> bool {
|
||||||
|
matches!(self, OrgRole::Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Members ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMember {
|
||||||
|
pub member_id: MemberId,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: OrgRole,
|
||||||
|
/// SSH public key string (openssh format: "ssh-ed25519 AAAA...")
|
||||||
|
pub ed25519_pubkey: String,
|
||||||
|
/// Collection slugs this member can access.
|
||||||
|
#[serde(default)]
|
||||||
|
pub collections: Vec<String>,
|
||||||
|
pub added_at: i64,
|
||||||
|
pub added_by: MemberId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMembers {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub members: Vec<OrgMember>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgMembers {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, members: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> {
|
||||||
|
self.members.iter().find(|m| &m.member_id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> {
|
||||||
|
self.members.iter_mut().find(|m| &m.member_id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
for m in &self.members {
|
||||||
|
if !m.member_id.is_valid() {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("invalid member_id: {}", m.member_id.0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgMembers {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collections ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CollectionDef {
|
||||||
|
pub slug: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub created_by: MemberId,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgCollections {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub collections: Vec<CollectionDef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgCollections {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, collections: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains_slug(&self, slug: &str) -> bool {
|
||||||
|
self.collections.iter().any(|c| c.slug == slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
for c in &self.collections {
|
||||||
|
if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("invalid collection slug: {:?}", c.slug)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgCollections {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org meta ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMeta {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub org_id: OrgId,
|
||||||
|
pub display_name: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgMeta {
|
||||||
|
pub fn new(display_name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: 1,
|
||||||
|
org_id: OrgId::new(),
|
||||||
|
display_name,
|
||||||
|
created_at: crate::time::now_unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org manifest ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgManifestEntry {
|
||||||
|
pub id: ItemId,
|
||||||
|
pub r#type: ItemType,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub modified: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
|
/// Collection this item belongs to.
|
||||||
|
pub collection: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgManifest {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub entries: Vec<OrgManifestEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgManifest {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, entries: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return only entries whose collection is in `member.collections`.
|
||||||
|
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
|
||||||
|
let granted: std::collections::HashSet<&str> =
|
||||||
|
member.collections.iter().map(|s| s.as_str()).collect();
|
||||||
|
Self {
|
||||||
|
schema_version: self.schema_version,
|
||||||
|
entries: self.entries.iter()
|
||||||
|
.filter(|e| granted.contains(e.collection.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgManifest {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ───────────────────
|
||||||
|
|
||||||
|
/// Generate a random 256-bit org master key.
|
||||||
|
pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
OsRng.fill_bytes(key.as_mut());
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path).
|
||||||
|
fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret {
|
||||||
|
use sha2::{Digest, Sha512};
|
||||||
|
let h = Sha512::digest(seed.as_ref());
|
||||||
|
let mut scalar = [0u8; 32];
|
||||||
|
scalar.copy_from_slice(&h[..32]);
|
||||||
|
// RFC 7748 clamping
|
||||||
|
scalar[0] &= 248;
|
||||||
|
scalar[31] &= 127;
|
||||||
|
scalar[31] |= 64;
|
||||||
|
x25519_dalek::StaticSecret::from(scalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an OpenSSH ed25519 public key string and return its X25519 form.
|
||||||
|
fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result<x25519_dalek::PublicKey> {
|
||||||
|
use ssh_key::PublicKey;
|
||||||
|
let pk = PublicKey::from_openssh(openssh.trim())
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?;
|
||||||
|
let ed_bytes = pk.key_data().ed25519()
|
||||||
|
.ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))?
|
||||||
|
.0;
|
||||||
|
let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?;
|
||||||
|
Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key.
|
||||||
|
///
|
||||||
|
/// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
|
||||||
|
pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result<Vec<u8>> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use x25519_dalek::EphemeralSecret;
|
||||||
|
|
||||||
|
let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?;
|
||||||
|
|
||||||
|
let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng);
|
||||||
|
let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk);
|
||||||
|
|
||||||
|
let shared = ephemeral_sk.diffie_hellman(&recipient_pk);
|
||||||
|
|
||||||
|
// Domain-separated KDF. All intermediates carrying the DH secret are held in
|
||||||
|
// Zeroizing so they are wiped on drop (H6).
|
||||||
|
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||||
|
kdf_input.extend_from_slice(shared.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||||
|
|
||||||
|
// Copy the digest straight into a Zeroizing array. The GenericArray returned
|
||||||
|
// by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and
|
||||||
|
// not enabled here), so we move the bytes into an owned [u8; 32] whose own
|
||||||
|
// Zeroize impl wipes them on drop.
|
||||||
|
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||||
|
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||||
|
|
||||||
|
let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(32 + encrypted.len());
|
||||||
|
out.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
out.extend_from_slice(&encrypted);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed.
|
||||||
|
pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
// Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext)
|
||||||
|
if wrapped.len() < 32 + 41 {
|
||||||
|
return Err(RelicarioError::Format("wrapped key blob too short".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut eph_bytes = [0u8; 32];
|
||||||
|
eph_bytes.copy_from_slice(&wrapped[..32]);
|
||||||
|
let ephemeral_pk = x25519_dalek::PublicKey::from(eph_bytes);
|
||||||
|
let encrypted = &wrapped[32..];
|
||||||
|
|
||||||
|
let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed);
|
||||||
|
let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk);
|
||||||
|
|
||||||
|
let shared = recipient_sk.diffie_hellman(&ephemeral_pk);
|
||||||
|
|
||||||
|
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||||
|
kdf_input.extend_from_slice(shared.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||||
|
|
||||||
|
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||||
|
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||||
|
|
||||||
|
let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?);
|
||||||
|
if plaintext.len() != 32 {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("unwrapped key has wrong length: {}", plaintext.len())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
key.copy_from_slice(&plaintext);
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_id_is_16_hex_chars() {
|
||||||
|
let id = MemberId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_ids_are_unique() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..1_000 {
|
||||||
|
assert!(seen.insert(MemberId::new().0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_id_is_16_hex_chars() {
|
||||||
|
let id = OrgId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_role_can_manage_members() {
|
||||||
|
assert!(OrgRole::Owner.can_manage_members());
|
||||||
|
assert!(OrgRole::Admin.can_manage_members());
|
||||||
|
assert!(!OrgRole::Member.can_manage_members());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_slug_validation_rejects_slash() {
|
||||||
|
let mut c = OrgCollections::new();
|
||||||
|
c.collections.push(CollectionDef {
|
||||||
|
slug: "bad/slug".into(),
|
||||||
|
display_name: "Bad".into(),
|
||||||
|
created_by: MemberId::new(),
|
||||||
|
created_at: 0,
|
||||||
|
});
|
||||||
|
assert!(c.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_for_member_restricts_collections() {
|
||||||
|
let mut manifest = OrgManifest::new();
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: crate::item_types::ItemType::SecureNote,
|
||||||
|
title: "A".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "prod".into(),
|
||||||
|
});
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: crate::item_types::ItemType::SecureNote,
|
||||||
|
title: "B".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "dev".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let member = OrgMember {
|
||||||
|
member_id: MemberId::new(),
|
||||||
|
display_name: "Alice".into(),
|
||||||
|
role: OrgRole::Member,
|
||||||
|
ed25519_pubkey: String::new(),
|
||||||
|
collections: vec!["prod".into()],
|
||||||
|
added_at: 0,
|
||||||
|
added_by: MemberId::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered = manifest.filter_for_member(&member);
|
||||||
|
assert_eq!(filtered.entries.len(), 1);
|
||||||
|
assert_eq!(filtered.entries[0].collection, "prod");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_org_key_is_32_bytes() {
|
||||||
|
let key = generate_org_key();
|
||||||
|
assert_eq!(key.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed
|
||||||
|
/// and expected X25519 public key are from ed25519-dalek's own reference
|
||||||
|
/// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a
|
||||||
|
/// HARD-CODED LITERAL — NOT recomputed by the production code path — so a
|
||||||
|
/// correlated cross-crate-version regression in the birational map (where
|
||||||
|
/// both our derivation and a naive re-derivation would drift together) is
|
||||||
|
/// still caught. If this test ever fails after a dep bump, the wrap/unwrap
|
||||||
|
/// keyspace changed and every existing `keys/<id>.enc` blob is invalidated.
|
||||||
|
#[test]
|
||||||
|
fn ed25519_to_x25519_pinned_rfc8032_vector() {
|
||||||
|
let seed: [u8; 32] =
|
||||||
|
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
// Derive the X25519 *public* key the same way wrap/unwrap derives the
|
||||||
|
// recipient's static secret from a seed.
|
||||||
|
let secret = ed25519_seed_to_x25519_secret(&seed);
|
||||||
|
let public = x25519_dalek::PublicKey::from(&secret);
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(public.as_bytes()),
|
||||||
|
"d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_unwrap_round_trip() {
|
||||||
|
// Generate an ed25519 keypair to act as the member's device key
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||||
|
let seed_zeroizing = Zeroizing::new(seed);
|
||||||
|
let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap");
|
||||||
|
|
||||||
|
assert_eq!(*org_key, *unwrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unwrap_with_wrong_seed_fails() {
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||||
|
|
||||||
|
let wrong_seed = Zeroizing::new([0xFFu8; 32]);
|
||||||
|
let result = unwrap_org_key(&wrapped, &wrong_seed);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
/// Current Unix timestamp in seconds.
|
/// Current Unix timestamp in seconds.
|
||||||
pub fn now_unix() -> i64 {
|
pub fn now_unix() -> i64 {
|
||||||
chrono::Utc::now().timestamp()
|
chrono::Utc::now().timestamp()
|
||||||
@@ -15,7 +17,7 @@ pub struct MonthYear {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MonthYear {
|
impl MonthYear {
|
||||||
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
|
pub fn new(month: u8, year: u16) -> std::result::Result<Self, &'static str> {
|
||||||
if !(1..=12).contains(&month) {
|
if !(1..=12).contains(&month) {
|
||||||
return Err("month must be 1..=12");
|
return Err("month must be 1..=12");
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,28 @@ impl MonthYear {
|
|||||||
}
|
}
|
||||||
Ok(Self { month, year })
|
Ok(Self { month, year })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a card-expiry string. Accepts `MM/YYYY`, `MM-YYYY`, and `MM/YY`
|
||||||
|
/// (two-digit year is taken as 20YY).
|
||||||
|
pub fn parse(s: &str) -> Result<Self> {
|
||||||
|
let invalid = |detail: String| RelicarioError::InvalidMonthYear(detail);
|
||||||
|
let (m_str, y_str) = s
|
||||||
|
.split_once(['/', '-'])
|
||||||
|
.ok_or_else(|| invalid(format!("expected MM/YYYY, got {s:?}")))?;
|
||||||
|
let month: u8 = m_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| invalid(format!("bad month {m_str:?}")))?;
|
||||||
|
let year: u16 = if y_str.len() == 2 {
|
||||||
|
2000 + y_str
|
||||||
|
.parse::<u16>()
|
||||||
|
.map_err(|_| invalid(format!("bad 2-digit year {y_str:?}")))?
|
||||||
|
} else {
|
||||||
|
y_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| invalid(format!("bad year {y_str:?}")))?
|
||||||
|
};
|
||||||
|
Self::new(month, year).map_err(|e| invalid(e.into()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -60,4 +84,30 @@ mod tests {
|
|||||||
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed, my);
|
assert_eq!(parsed, my);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_mm_slash_yyyy_and_mm_dash_yyyy() {
|
||||||
|
assert_eq!(MonthYear::parse("01/2026").unwrap(), MonthYear::new(1, 2026).unwrap());
|
||||||
|
assert_eq!(MonthYear::parse("12/2099").unwrap(), MonthYear::new(12, 2099).unwrap());
|
||||||
|
assert_eq!(MonthYear::parse("07-2030").unwrap(), MonthYear::new(7, 2030).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_mm_slash_yy() {
|
||||||
|
assert_eq!(MonthYear::parse("01/26").unwrap(), MonthYear::new(1, 2026).unwrap());
|
||||||
|
assert_eq!(MonthYear::parse("12/99").unwrap(), MonthYear::new(12, 2099).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_malformed() {
|
||||||
|
assert!(matches!(
|
||||||
|
MonthYear::parse("garbage"),
|
||||||
|
Err(RelicarioError::InvalidMonthYear(_))
|
||||||
|
));
|
||||||
|
assert!(MonthYear::parse("13/2026").is_err()); // bad month
|
||||||
|
assert!(MonthYear::parse("01/1999").is_err()); // pre-2000
|
||||||
|
assert!(MonthYear::parse("01/2100").is_err()); // post-2099
|
||||||
|
assert!(MonthYear::parse("/2026").is_err()); // empty month
|
||||||
|
assert!(MonthYear::parse("01/").is_err()); // empty year
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::crypto::{decrypt, encrypt};
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
use crate::manifest::Manifest;
|
use crate::manifest::Manifest;
|
||||||
|
use crate::org::OrgManifest;
|
||||||
use crate::settings::VaultSettings;
|
use crate::settings::VaultSettings;
|
||||||
|
|
||||||
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||||
@@ -52,6 +53,19 @@ pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> R
|
|||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||||
|
let json = serde_json::to_vec(manifest)?;
|
||||||
|
let plaintext = Zeroizing::new(json);
|
||||||
|
encrypt(org_key, plaintext.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_org_manifest(encrypted: &[u8], org_key: &Zeroizing<[u8; 32]>) -> Result<OrgManifest> {
|
||||||
|
let plaintext = decrypt(org_key, encrypted)?;
|
||||||
|
let plaintext = Zeroizing::new(plaintext);
|
||||||
|
let manifest: OrgManifest = serde_json::from_slice(&plaintext)?;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -87,4 +101,27 @@ mod tests {
|
|||||||
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
||||||
s.attachment_caps.per_attachment_max_bytes);
|
s.attachment_caps.per_attachment_max_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_manifest_round_trip() {
|
||||||
|
use crate::org::{OrgManifest, OrgManifestEntry};
|
||||||
|
use crate::ids::ItemId;
|
||||||
|
use crate::item_types::ItemType;
|
||||||
|
|
||||||
|
let mut m = OrgManifest::new();
|
||||||
|
m.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: ItemType::SecureNote,
|
||||||
|
title: "test".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "prod".into(),
|
||||||
|
});
|
||||||
|
let key = key();
|
||||||
|
let bytes = encrypt_org_manifest(&m, &key).unwrap();
|
||||||
|
let decoded = decrypt_org_manifest(&bytes, &key).unwrap();
|
||||||
|
assert_eq!(decoded.entries.len(), 1);
|
||||||
|
assert_eq!(decoded.entries[0].collection, "prod");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
crates/relicario-core/tests/org.rs
Normal file
120
crates/relicario-core/tests/org.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use relicario_core::{
|
||||||
|
generate_org_key, wrap_org_key, unwrap_org_key,
|
||||||
|
encrypt_org_manifest, decrypt_org_manifest,
|
||||||
|
OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole,
|
||||||
|
MemberId, ItemId,
|
||||||
|
};
|
||||||
|
use relicario_core::item_types::ItemType;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) {
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
(Zeroizing::new(seed), pubkey_openssh)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_key_wrap_unwrap_round_trip() {
|
||||||
|
let (seed, pubkey) = make_member_keypair();
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap");
|
||||||
|
let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap");
|
||||||
|
assert_eq!(*org_key, *unwrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_member_cannot_decrypt_after_rotation() {
|
||||||
|
// Alice and Bob both get access
|
||||||
|
let (alice_seed, alice_pubkey) = make_member_keypair();
|
||||||
|
let (_bob_seed, bob_pubkey) = make_member_keypair();
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice");
|
||||||
|
let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob");
|
||||||
|
|
||||||
|
// Rotate: new key, only Bob gets re-wrapped
|
||||||
|
let new_org_key = generate_org_key();
|
||||||
|
let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new");
|
||||||
|
|
||||||
|
// Alice tries to use old org_key — she can still decrypt old items,
|
||||||
|
// but new_bob_wrapped was encrypted with new_org_key, not org_key.
|
||||||
|
// Verify: unwrapping new_bob_wrapped with Alice's seed fails.
|
||||||
|
let result = unwrap_org_key(&new_bob_wrapped, &alice_seed);
|
||||||
|
assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_manifest_filter_restricts_to_granted_collections() {
|
||||||
|
let mut manifest = OrgManifest::new();
|
||||||
|
for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] {
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: ItemType::SecureNote,
|
||||||
|
title: title.to_string(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: collection.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let member = OrgMember {
|
||||||
|
member_id: MemberId::new(),
|
||||||
|
display_name: "Alice".into(),
|
||||||
|
role: OrgRole::Member,
|
||||||
|
ed25519_pubkey: String::new(),
|
||||||
|
collections: vec!["prod".into()],
|
||||||
|
added_at: 0,
|
||||||
|
added_by: MemberId::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered = manifest.filter_for_member(&member);
|
||||||
|
assert_eq!(filtered.entries.len(), 2);
|
||||||
|
assert!(filtered.entries.iter().all(|e| e.collection == "prod"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_manifest_encrypt_decrypt_round_trip() {
|
||||||
|
let key = generate_org_key();
|
||||||
|
let mut manifest = OrgManifest::new();
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: ItemType::Login,
|
||||||
|
title: "GitHub".into(),
|
||||||
|
tags: vec!["work".into()],
|
||||||
|
modified: 1748000000,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "eng-tools".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt");
|
||||||
|
let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt");
|
||||||
|
|
||||||
|
assert_eq!(decrypted.entries.len(), 1);
|
||||||
|
assert_eq!(decrypted.entries[0].title, "GitHub");
|
||||||
|
assert_eq!(decrypted.entries[0].collection, "eng-tools");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn members_validation_rejects_invalid_id() {
|
||||||
|
let mut members = OrgMembers::new();
|
||||||
|
members.members.push(OrgMember {
|
||||||
|
member_id: MemberId("not-hex-lol!!".to_string()),
|
||||||
|
display_name: "Bad".into(),
|
||||||
|
role: OrgRole::Member,
|
||||||
|
ed25519_pubkey: String::new(),
|
||||||
|
collections: vec![],
|
||||||
|
added_at: 0,
|
||||||
|
added_by: MemberId::new(),
|
||||||
|
});
|
||||||
|
assert!(members.validate().is_err());
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-server"
|
name = "relicario-server"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
description = "Pre-receive Git hook for relicario password manager"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "relicario_server"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "relicario-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
relicario-core = { path = "../relicario-core" }
|
relicario-core = { path = "../relicario-core" }
|
||||||
|
|||||||
76
crates/relicario-server/src/lib.rs
Normal file
76
crates/relicario-server/src/lib.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//! Library surface for relicario-server, exposing pure helpers used by the
|
||||||
|
//! pre-receive hooks so they can be unit-tested.
|
||||||
|
|
||||||
|
/// Classification of a single changed path inside an org repo.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PathClass {
|
||||||
|
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
|
||||||
|
Protected,
|
||||||
|
/// `items/<slug>/<id>.enc` and `attachments/<slug>/<item-id>/<att-id>.enc` —
|
||||||
|
/// writer must hold a grant for `<slug>`.
|
||||||
|
Item { collection: String },
|
||||||
|
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
|
||||||
|
/// per-commit signature check (signer must be a current member).
|
||||||
|
Unrestricted,
|
||||||
|
/// Structurally invalid path; commit must be rejected.
|
||||||
|
Rejected(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a repo-relative path. Pure; no I/O.
|
||||||
|
pub fn classify_path(path: &str) -> PathClass {
|
||||||
|
match path {
|
||||||
|
"members.json" | "collections.json" | "org.json" => return PathClass::Protected,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = path.strip_prefix("items/") {
|
||||||
|
// Expect exactly: <slug>/<id>.enc → two segments after the prefix.
|
||||||
|
let segments: Vec<&str> = rest.split('/').collect();
|
||||||
|
if segments.len() != 2 {
|
||||||
|
return PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string());
|
||||||
|
}
|
||||||
|
let slug = segments[0];
|
||||||
|
if slug.is_empty() {
|
||||||
|
return PathClass::Rejected("empty collection slug in items path".to_string());
|
||||||
|
}
|
||||||
|
// Defense-in-depth: mirror `OrgCollections::validate` — a slug containing
|
||||||
|
// '.' (e.g. a `..`/`.` path-traversal attempt) is structurally invalid.
|
||||||
|
// git normalizes most `./` away before the hook sees the path, so this is
|
||||||
|
// unreachable today; it keeps the hook self-defensive regardless.
|
||||||
|
if slug.contains('.') {
|
||||||
|
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
|
||||||
|
}
|
||||||
|
return PathClass::Item { collection: slug.to_string() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = path.strip_prefix("attachments/") {
|
||||||
|
// Expect exactly: <slug>/<item-id>/<att-id>.enc → three segments.
|
||||||
|
let segments: Vec<&str> = rest.split('/').collect();
|
||||||
|
if segments.len() != 3 {
|
||||||
|
return PathClass::Rejected(
|
||||||
|
"attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string());
|
||||||
|
}
|
||||||
|
let slug = segments[0];
|
||||||
|
if slug.is_empty() {
|
||||||
|
return PathClass::Rejected("empty collection slug in attachments path".to_string());
|
||||||
|
}
|
||||||
|
if slug.contains('.') {
|
||||||
|
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
|
||||||
|
}
|
||||||
|
return PathClass::Item { collection: slug.to_string() };
|
||||||
|
}
|
||||||
|
|
||||||
|
PathClass::Unrestricted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the `schema_version` field from any org JSON document.
|
||||||
|
/// Returns an error if the field is absent or not a u32.
|
||||||
|
pub fn extract_schema_version(json: &str) -> Result<u32, String> {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(json).map_err(|e| format!("parse json: {e}"))?;
|
||||||
|
value
|
||||||
|
.get("schema_version")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|n| n as u32)
|
||||||
|
.ok_or_else(|| "missing or non-integer schema_version".to_string())
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ use std::process::Command;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
||||||
|
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers, OrgRole};
|
||||||
|
use relicario_server::{classify_path, extract_schema_version, PathClass};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "relicario-server")]
|
#[command(name = "relicario-server")]
|
||||||
@@ -23,6 +25,13 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
/// Generate a pre-receive hook script.
|
/// Generate a pre-receive hook script.
|
||||||
GenerateHook,
|
GenerateHook,
|
||||||
|
/// Verify a commit to an org vault: signature + role/path authorization.
|
||||||
|
VerifyOrgCommit {
|
||||||
|
/// The commit SHA to verify.
|
||||||
|
commit: String,
|
||||||
|
},
|
||||||
|
/// Generate an org pre-receive hook script.
|
||||||
|
GenerateOrgHook,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -31,6 +40,8 @@ fn main() -> Result<()> {
|
|||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
||||||
Commands::GenerateHook => generate_hook(),
|
Commands::GenerateHook => generate_hook(),
|
||||||
|
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
|
||||||
|
Commands::GenerateOrgHook => generate_org_hook(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,3 +198,408 @@ fn git_show(commit: &str, path: &str) -> Result<String> {
|
|||||||
|
|
||||||
Ok(String::from_utf8(output.stdout)?)
|
Ok(String::from_utf8(output.stdout)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify the SSH signature on `commit` against the given org members and return
|
||||||
|
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
|
||||||
|
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
|
||||||
|
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
|
||||||
|
// Build a temp allowed-signers file from every current member's pubkey.
|
||||||
|
let tmp = match tempfile::tempdir() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let allowed_path = tmp.path().join("allowed_signers");
|
||||||
|
let mut allowed_body = String::new();
|
||||||
|
for m in &members.members {
|
||||||
|
allowed_body.push_str("relicario ");
|
||||||
|
allowed_body.push_str(m.ed25519_pubkey.trim());
|
||||||
|
allowed_body.push('\n');
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::write(&allowed_path, &allowed_body) {
|
||||||
|
eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run git verify-commit --raw with the allowed-signers file injected.
|
||||||
|
let output = match Command::new("git")
|
||||||
|
.args(["verify-commit", "--raw", commit])
|
||||||
|
.env("GIT_CONFIG_COUNT", "1")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||||
|
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|
||||||
|
// The org hook builds allowed_signers from EVERY current member, so a clean
|
||||||
|
// `git verify-commit` exit IS the security gate: a non-zero exit means the
|
||||||
|
// commit was unsigned, tampered, or signed by a non-member. Make that
|
||||||
|
// property explicit rather than relying on the stderr regex alone (regex
|
||||||
|
// output is fragile across git versions). The fingerprint parse + member
|
||||||
|
// mapping below then identifies WHICH member signed.
|
||||||
|
if !output.status.success() {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — signature did not verify against current members \
|
||||||
|
(git verify-commit exit {}): {}",
|
||||||
|
output.status.code().unwrap_or(-1),
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the SHA-256 fingerprint from stderr (same regex as verify_commit).
|
||||||
|
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||||
|
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||||
|
Some(m) => m.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — no valid signature found (stderr: {})",
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map fingerprint → member via relicario_core::fingerprint over each pubkey.
|
||||||
|
for m in &members.members {
|
||||||
|
if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
|
||||||
|
if fp == signing_fp {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_org_commit(commit: &str) -> Result<()> {
|
||||||
|
// Determine parent count from %P (space-separated parent SHAs; empty = root).
|
||||||
|
let parents_out = Command::new("git")
|
||||||
|
.args(["show", "-s", "--format=%P", commit])
|
||||||
|
.output()
|
||||||
|
.context("git show parents")?;
|
||||||
|
let parents_line = String::from_utf8_lossy(&parents_out.stdout);
|
||||||
|
let parents: Vec<&str> = parents_line.split_whitespace().collect();
|
||||||
|
|
||||||
|
// Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
|
||||||
|
if parents.len() > 1 {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — merge commits are not allowed in org vaults \
|
||||||
|
({} parents); rebase instead",
|
||||||
|
parents.len()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let is_root = parents.is_empty();
|
||||||
|
|
||||||
|
// Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
|
||||||
|
let members_json = match git_show(commit, "members.json") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
if is_root {
|
||||||
|
eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let members: OrgMembers =
|
||||||
|
serde_json::from_str(&members_json).context("parse members.json")?;
|
||||||
|
if members.members.is_empty() {
|
||||||
|
if is_root {
|
||||||
|
eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
eprintln!("REJECT: org commit {commit} — members.json has no members");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
members
|
||||||
|
.validate()
|
||||||
|
.map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;
|
||||||
|
|
||||||
|
// Verify the signature and resolve the signing member (exits on failure).
|
||||||
|
let signer = verify_org_signer(commit, &members);
|
||||||
|
|
||||||
|
// Enumerate changed paths. Root has no parent to diff, so use ls-tree.
|
||||||
|
let changed_paths: Vec<String> = if is_root {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(["ls-tree", "-r", "--name-only", commit])
|
||||||
|
.output()
|
||||||
|
.context("git ls-tree")?;
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
|
||||||
|
.output()
|
||||||
|
.context("git diff-tree")?;
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authorize each changed path against the signing member's role/grants.
|
||||||
|
// collections.json (as of this commit) is loaded lazily on the first item
|
||||||
|
// path, for the L5 slug-existence check.
|
||||||
|
let mut collection_slugs: Option<Vec<String>> = None;
|
||||||
|
for path in &changed_paths {
|
||||||
|
match classify_path(path) {
|
||||||
|
PathClass::Rejected(why) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
PathClass::Protected => {
|
||||||
|
if !signer.role.can_manage_members() {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
|
||||||
|
signer.display_name, signer.role
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
// Privilege-escalation gate: only an Owner may INTRODUCE or
|
||||||
|
// ELEVATE an owner/admin. An Admin may write members.json but
|
||||||
|
// must not mint owners/admins server-side (spec §148/158/271).
|
||||||
|
if path == "members.json" {
|
||||||
|
enforce_owner_only_elevation(commit, is_root, &members, &signer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathClass::Item { collection } => {
|
||||||
|
// The signing member must hold an explicit grant for the slug.
|
||||||
|
if !signer.collections.iter().any(|c| c == &collection) {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
|
||||||
|
signer.display_name
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
// Slug-existence (L5): the collection must exist in
|
||||||
|
// collections.json AS OF THIS COMMIT. A write into a
|
||||||
|
// granted-but-deleted (or never-created) collection is rejected.
|
||||||
|
let known = collection_slugs.get_or_insert_with(|| {
|
||||||
|
git_show(commit, "collections.json")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<OrgCollections>(&s).ok())
|
||||||
|
.map(|c| c.collections.into_iter().map(|d| d.slug).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
if !known.iter().any(|s| s == &collection) {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathClass::Unrestricted => {
|
||||||
|
// keys/<id>.enc, manifest.enc, etc. — signature check already passed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema-version monotonicity for the three JSON files (Task C2).
|
||||||
|
enforce_schema_monotonicity(commit, is_root, &changed_paths)?;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
|
||||||
|
signer.display_name,
|
||||||
|
signer.role,
|
||||||
|
changed_paths.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject the commit unless every newly-introduced or elevated owner/admin is
|
||||||
|
/// authorized. The signer's AUTHORITY is their role in the PARENT state — the role
|
||||||
|
/// they held BEFORE this commit — NOT the role this commit may grant them. Reading
|
||||||
|
/// `signer.role` (which is parsed from the post-change members.json) would let an
|
||||||
|
/// admin self-promote to owner and then pass this very gate with the owner role
|
||||||
|
/// they are minting — the exact escalation H-C1 exists to stop. We diff the new
|
||||||
|
/// members.json against the parent's by member_id and require an owner-authority
|
||||||
|
/// signer for any member that BECOMES owner/admin (new entry, or a role elevated
|
||||||
|
/// up to owner/admin). On genesis (root) the sole bootstrap owner is allowed.
|
||||||
|
///
|
||||||
|
/// `git_show_parent` is defined alongside `enforce_schema_monotonicity` below.
|
||||||
|
fn enforce_owner_only_elevation(
|
||||||
|
commit: &str,
|
||||||
|
is_root: bool,
|
||||||
|
new_members: &OrgMembers,
|
||||||
|
signer: &OrgMember,
|
||||||
|
) {
|
||||||
|
let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin);
|
||||||
|
|
||||||
|
// Genesis: the bootstrap commit introduces the sole owner; allow it.
|
||||||
|
if is_root {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent baseline. If members.json did not exist in the parent, every
|
||||||
|
// privileged member here is "new" and must be owner-signed.
|
||||||
|
let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") {
|
||||||
|
Ok(s) => serde_json::from_str::<OrgMembers>(&s)
|
||||||
|
.map(|m| {
|
||||||
|
m.members
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.member_id.0, m.role))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
let parent_role = |id: &str| -> Option<OrgRole> {
|
||||||
|
parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The signer's authority = their PARENT role. A member absent from the parent
|
||||||
|
// (brand new) has no prior authority and cannot mint owners/admins.
|
||||||
|
let signer_parent = parent_role(signer.member_id.as_str());
|
||||||
|
let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());
|
||||||
|
|
||||||
|
for m in &new_members.members {
|
||||||
|
if !is_privileged(m.role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip ONLY if the role is unchanged from the parent (a no-op same-role
|
||||||
|
// entry). Any CHANGE into a privileged role — a new privileged member,
|
||||||
|
// Member→Admin/Owner, or Admin→Owner — must be owner-signed.
|
||||||
|
if parent_role(m.member_id.as_str()) == Some(m.role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A new owner/admin, or a member elevated to owner/admin → owner-only,
|
||||||
|
// judged by the signer's PRE-commit authority.
|
||||||
|
if !signer_may_manage_owners {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
|
||||||
|
elevate owner/admin '{}' to {:?}; only an owner may",
|
||||||
|
signer.display_name, signer_parent, m.display_name, m.role
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_org_hook() -> Result<()> {
|
||||||
|
print!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
# Relicario org pre-receive hook -- verify signatures + role/path authorization
|
||||||
|
|
||||||
|
while read oldrev newrev refname; do
|
||||||
|
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
|
||||||
|
|
||||||
|
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
commits=$(git rev-list "$newrev")
|
||||||
|
else
|
||||||
|
commits=$(git rev-list "$oldrev..$newrev")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for commit in $commits; do
|
||||||
|
relicario-server verify-org-commit "$commit" || exit 1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For each protected JSON file changed in this commit, ensure schema_version did
|
||||||
|
/// not decrease vs the parent commit, and re-validate collections.json structure.
|
||||||
|
fn enforce_schema_monotonicity(
|
||||||
|
commit: &str,
|
||||||
|
is_root: bool,
|
||||||
|
changed_paths: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];
|
||||||
|
|
||||||
|
for file in VERSIONED {
|
||||||
|
if !changed_paths.iter().any(|p| p == file) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deletion of a protected file is not allowed.
|
||||||
|
let new_content = match git_show(commit, file) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — protected file `{file}` was deleted; \
|
||||||
|
org vaults never delete {file}"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_version = match extract_schema_version(&new_content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// collections.json structural validation.
|
||||||
|
if file == "collections.json" {
|
||||||
|
match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
|
||||||
|
Ok(c) => {
|
||||||
|
if let Err(e) = c.validate() {
|
||||||
|
eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the root commit there is no parent baseline; any starting version is fine.
|
||||||
|
if is_root {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent version: if the file did not exist in the parent (newly added),
|
||||||
|
// there is no prior version to regress against — accept.
|
||||||
|
if let Ok(old_content) = git_show_parent(commit, file) {
|
||||||
|
let old_version = match extract_schema_version(&old_content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if new_version < old_version {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — `{file}` schema_version decreased \
|
||||||
|
({old_version} -> {new_version})"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
|
||||||
|
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["show", &format!("{}^:{}", commit, path)])
|
||||||
|
.output()
|
||||||
|
.context("git show parent")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!("git show {}^:{} failed", commit, path);
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8(output.stdout)?)
|
||||||
|
}
|
||||||
|
|||||||
121
crates/relicario-server/tests/org_hook.rs
Normal file
121
crates/relicario-server/tests/org_hook.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Integration tests for relicario-server org-hook path classification.
|
||||||
|
|
||||||
|
use relicario_server::{classify_path, PathClass};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protected_files_are_classified_protected() {
|
||||||
|
assert_eq!(classify_path("members.json"), PathClass::Protected);
|
||||||
|
assert_eq!(classify_path("collections.json"), PathClass::Protected);
|
||||||
|
assert_eq!(classify_path("org.json"), PathClass::Protected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_write_yields_collection_slug() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("items/prod/a1b2c3d4e5f6a1b2.enc"),
|
||||||
|
PathClass::Item { collection: "prod".to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_write_nested_slug_is_rejected() {
|
||||||
|
// Slugs cannot contain '/', so a path with extra segments is malformed → Rejected.
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("items/prod/sub/x.enc"),
|
||||||
|
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_blobs_and_manifest_are_unrestricted() {
|
||||||
|
// keys/<id>.enc and manifest.enc are written by org operations; the SIGNATURE
|
||||||
|
// check (every commit must be signed by a current member) is the gate for them.
|
||||||
|
assert_eq!(classify_path("keys/a1b2c3d4e5f6a1b2.enc"), PathClass::Unrestricted);
|
||||||
|
assert_eq!(classify_path("manifest.enc"), PathClass::Unrestricted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn items_without_slug_segment_are_rejected() {
|
||||||
|
// Flat items/<id>.enc (the OLD, now-removed layout) is no longer valid.
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("items/a1b2c3d4e5f6a1b2.enc"),
|
||||||
|
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_slug_segment_is_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("items//x.enc"),
|
||||||
|
PathClass::Rejected("empty collection slug in items path".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotted_slug_is_rejected() {
|
||||||
|
// Defense-in-depth (mirrors OrgCollections::validate): a slug containing '.'
|
||||||
|
// — e.g. a ".."/"." path-traversal attempt — is rejected.
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("items/../x.enc"),
|
||||||
|
PathClass::Rejected("invalid collection slug: \"..\"".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
use relicario_server::extract_schema_version;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_schema_version_reads_field() {
|
||||||
|
let json = r#"{ "schema_version": 3, "members": [] }"#;
|
||||||
|
assert_eq!(extract_schema_version(json).unwrap(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_schema_version_errors_on_missing_field() {
|
||||||
|
let json = r#"{ "members": [] }"#;
|
||||||
|
assert!(extract_schema_version(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_schema_version_errors_on_garbage() {
|
||||||
|
assert!(extract_schema_version("not json").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_path_is_collection_scoped() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("attachments/prod/a1b2c3d4e5f6a1b2/0011223344556677.enc"),
|
||||||
|
PathClass::Item { collection: "prod".to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_wrong_segment_count_is_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("attachments/prod/onlytwo.enc"),
|
||||||
|
PathClass::Rejected("attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_empty_or_dotted_slug_is_rejected() {
|
||||||
|
assert!(matches!(classify_path("attachments//item/att.enc"), PathClass::Rejected(_)));
|
||||||
|
assert!(matches!(classify_path("attachments/../item/att.enc"), PathClass::Rejected(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachments_prefix_alone_is_rejected_not_unrestricted() {
|
||||||
|
// `attachments/` with no slug/item/att segments must be Rejected, NOT fall
|
||||||
|
// through to Unrestricted — that fall-through was the authz gap this closes.
|
||||||
|
assert!(matches!(classify_path("attachments/"), PathClass::Rejected(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_att_id_segment_may_contain_dots() {
|
||||||
|
// The `.`-free guard applies to the slug (segment[0]) ONLY; the att-id segment
|
||||||
|
// legitimately carries `.enc` and is unharmed by additional dots — proving the
|
||||||
|
// guard is not a blanket "reject any dotted segment".
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("attachments/eng/a1b2c3d4e5f6a1b2/00112233.aux.enc"),
|
||||||
|
PathClass::Item { collection: "eng".to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
229
crates/relicario-server/tests/org_hook_signed.rs
Normal file
229
crates/relicario-server/tests/org_hook_signed.rs
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
//! Integration tests for `relicario-server verify-org-commit` privilege gating.
|
||||||
|
//!
|
||||||
|
//! H-C1: only an Owner may introduce or elevate an owner/admin. An Admin who
|
||||||
|
//! writes members.json must not be able to mint owners/admins.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use assert_cmd::Command as AssertCommand;
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use relicario_core::device::generate_keypair;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, String) {
|
||||||
|
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||||
|
let priv_path = dir.join(format!("{name}.key"));
|
||||||
|
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
}
|
||||||
|
(priv_path, pub_line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(repo: &Path, args: &[&str]) {
|
||||||
|
let status = Command::new("git").current_dir(repo).args(args).status().unwrap();
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// members.json content with two members; `member_id`s are fixed 16-hex.
|
||||||
|
fn members_json(owner_pub: &str, admin_pub: &str, admin_role: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{
|
||||||
|
"schema_version": 1,
|
||||||
|
"members": [
|
||||||
|
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
|
||||||
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }},
|
||||||
|
{{ "member_id": "2222222222222222", "display_name": "Admin", "role": "{admin_role}",
|
||||||
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
|
||||||
|
]
|
||||||
|
}}"#,
|
||||||
|
owner_pub.trim(),
|
||||||
|
admin_pub.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage members.json, sign the commit with `signing_key`, return its SHA.
|
||||||
|
fn signed_members_commit(
|
||||||
|
repo: &Path,
|
||||||
|
signing_key: &Path,
|
||||||
|
allowed: &Path,
|
||||||
|
msg: &str,
|
||||||
|
content: &str,
|
||||||
|
) -> String {
|
||||||
|
fs::write(repo.join("members.json"), content).unwrap();
|
||||||
|
git(repo, &["add", "members.json"]);
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(repo)
|
||||||
|
.args([
|
||||||
|
"-c", "gpg.format=ssh",
|
||||||
|
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||||
|
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
|
||||||
|
"commit", "-S", "-q", "-m", msg,
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
let out = Command::new("git").current_dir(repo).args(["rev-parse", "HEAD"]).output().unwrap();
|
||||||
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up an org repo whose root commit (signed by the owner) registers an
|
||||||
|
/// owner + an admin. Returns (repo tmp, owner priv, admin priv, allowed file).
|
||||||
|
fn bootstrap() -> (TempDir, PathBuf, PathBuf, PathBuf) {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
git(repo, &["init", "-q", "-b", "main"]);
|
||||||
|
git(repo, &["config", "user.email", "t@t"]);
|
||||||
|
git(repo, &["config", "user.name", "t"]);
|
||||||
|
|
||||||
|
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
|
||||||
|
let (admin_priv, admin_pub) = write_keypair(repo, "admin");
|
||||||
|
|
||||||
|
let allowed = repo.join("allowed_signers");
|
||||||
|
fs::write(
|
||||||
|
&allowed,
|
||||||
|
format!("relicario {}\nrelicario {}\n", owner_pub.trim(), admin_pub.trim()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Genesis: owner registers both members (admin starts as `admin`).
|
||||||
|
let genesis = members_json(&owner_pub, &admin_pub, "admin");
|
||||||
|
signed_members_commit(repo, &owner_priv, &allowed, "org-init", &genesis);
|
||||||
|
|
||||||
|
// also write org.json + collections.json so later commits are well-formed
|
||||||
|
fs::write(repo.join("org.json"),
|
||||||
|
r#"{"schema_version":1,"org_id":"abc0abc0abc0abc0","display_name":"Acme","created_at":0}"#).unwrap();
|
||||||
|
fs::write(repo.join("collections.json"), r#"{"schema_version":1,"collections":[]}"#).unwrap();
|
||||||
|
git(repo, &["add", "org.json", "collections.json"]);
|
||||||
|
// sign this housekeeping commit with the owner too
|
||||||
|
let _ = signed_members_commit(repo, &owner_priv, &allowed, "scaffold",
|
||||||
|
&members_json(&owner_pub, &admin_pub, "admin"));
|
||||||
|
|
||||||
|
(tmp, owner_priv, admin_priv, allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_self_promote_to_owner_is_rejected() {
|
||||||
|
let (tmp, owner_priv, admin_priv, allowed) = bootstrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
let owner_pub = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
||||||
|
// Reconstruct pubkeys from the allowed_signers file (two "relicario <pub>" lines).
|
||||||
|
let lines: Vec<String> = owner_pub.lines()
|
||||||
|
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
||||||
|
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
||||||
|
let _ = owner_priv;
|
||||||
|
|
||||||
|
// Admin signs a members.json that elevates THEMSELVES to owner.
|
||||||
|
let escalated = members_json(&op, &ap, "owner");
|
||||||
|
let sha = signed_members_commit(repo, &admin_priv, &allowed, "self-promote", &escalated);
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-org-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("only an owner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn owner_promoting_an_admin_is_accepted() {
|
||||||
|
let (tmp, owner_priv, _admin_priv, allowed) = bootstrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
let allowed_body = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
||||||
|
let lines: Vec<String> = allowed_body.lines()
|
||||||
|
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
||||||
|
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
||||||
|
|
||||||
|
// Owner signs a members.json that elevates the admin to owner — allowed.
|
||||||
|
let promoted = members_json(&op, &ap, "owner");
|
||||||
|
let sha = signed_members_commit(repo, &owner_priv, &allowed, "promote-admin", &promoted);
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-org-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_signed_by_non_member_is_rejected() {
|
||||||
|
// A commit signed by a key that is NOT in members.json must be rejected:
|
||||||
|
// verify_org_signer rebuilds allowed_signers from the current members only,
|
||||||
|
// so a non-member signature fails `git verify-commit`.
|
||||||
|
let (tmp, _owner_priv, _admin_priv, allowed) = bootstrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
|
||||||
|
// A stranger key, never registered as a member.
|
||||||
|
let (stranger_priv, _stranger_pub) = write_keypair(repo, "stranger");
|
||||||
|
|
||||||
|
// Stranger signs a commit touching an UNRESTRICTED file (members.json stays
|
||||||
|
// owner+admin, so allowed_signers excludes the stranger).
|
||||||
|
fs::write(repo.join("manifest.enc"), b"\x02ciphertext").unwrap();
|
||||||
|
git(repo, &["add", "manifest.enc"]);
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(repo)
|
||||||
|
.args([
|
||||||
|
"-c", "gpg.format=ssh",
|
||||||
|
"-c", &format!("user.signingkey={}", stranger_priv.display()),
|
||||||
|
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
|
||||||
|
"commit", "-S", "-q", "-m", "stranger-write",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
let out = Command::new("git")
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["rev-parse", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let sha = String::from_utf8(out.stdout).unwrap().trim().to_string();
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-org-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("REJECT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn genesis_bootstrap_with_sole_owner_is_accepted() {
|
||||||
|
// A root (parent-less) commit registering the sole owner, signed by that
|
||||||
|
// owner, is the genesis bootstrap and must be accepted.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
git(repo, &["init", "-q", "-b", "main"]);
|
||||||
|
git(repo, &["config", "user.email", "t@t"]);
|
||||||
|
git(repo, &["config", "user.name", "t"]);
|
||||||
|
|
||||||
|
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
|
||||||
|
let allowed = repo.join("allowed_signers");
|
||||||
|
fs::write(&allowed, format!("relicario {}\n", owner_pub.trim())).unwrap();
|
||||||
|
|
||||||
|
let sole_owner = format!(
|
||||||
|
r#"{{
|
||||||
|
"schema_version": 1,
|
||||||
|
"members": [
|
||||||
|
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
|
||||||
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
|
||||||
|
]
|
||||||
|
}}"#,
|
||||||
|
owner_pub.trim()
|
||||||
|
);
|
||||||
|
// First commit in a fresh repo → root (is_root == true).
|
||||||
|
let sha = signed_members_commit(repo, &owner_priv, &allowed, "org-init", &sole_owner);
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-org-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.5.0"
|
version = "0.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "WASM bindings for relicario password manager"
|
description = "WASM bindings for relicario password manager"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|||||||
@@ -330,6 +330,32 @@ pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsEr
|
|||||||
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
|
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pure parsers (no session needed) ────────────────────────────────────────
|
||||||
|
|
||||||
|
use relicario_core::{base32 as core_base32, mime as core_mime, MonthYear};
|
||||||
|
|
||||||
|
/// Parse a card-expiry string (`MM/YYYY` / `MM-YYYY` / `MM/YY`).
|
||||||
|
/// Returns a plain `{ month, year }` object on success.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn parse_month_year(s: &str) -> Result<JsValue, JsError> {
|
||||||
|
let my = MonthYear::parse(s).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
js_value_for(&my)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an RFC 4648 base32 string (case-insensitive, optional padding,
|
||||||
|
/// whitespace-stripped). Returned as `Uint8Array` on the JS side.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn base32_decode_lenient(s: &str) -> Result<Vec<u8>, JsError> {
|
||||||
|
core_base32::decode_rfc4648_lenient(s).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guess a MIME type from a filename's extension. Returns
|
||||||
|
/// `application/octet-stream` for unknown or missing extensions.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn guess_mime(filename: &str) -> String {
|
||||||
|
core_mime::guess_for_extension(filename).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
use relicario_core::item_types::{TotpConfig, compute_totp_code};
|
use relicario_core::item_types::{TotpConfig, compute_totp_code};
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
@@ -624,4 +650,24 @@ mod session_tests {
|
|||||||
// Should fail with a header validation error.
|
// Should fail with a header validation error.
|
||||||
assert!(err.is_err());
|
assert!(err.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base32_decode_lenient_round_trips_known_vector() {
|
||||||
|
let bytes = super::base32_decode_lenient("MZXW6YTBOI").unwrap();
|
||||||
|
assert_eq!(bytes, b"foobar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guess_mime_known_and_unknown_extensions() {
|
||||||
|
assert_eq!(super::guess_mime("doc.pdf"), "application/pdf");
|
||||||
|
assert_eq!(super::guess_mime("photo.JPEG"), "image/jpeg");
|
||||||
|
assert_eq!(super::guess_mime("file.xyz"), "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error paths and JsValue serialization can't be exercised natively —
|
||||||
|
// JsError::new and serde_wasm_bindgen::Serializer call wasm-bindgen
|
||||||
|
// imports that panic off-wasm (same constraint as
|
||||||
|
// `parse_lastpass_csv_json_propagates_header_errors` above). Those
|
||||||
|
// paths are covered in core: `time::tests::parse_rejects_malformed`
|
||||||
|
// and `base32::tests::decode_rfc4648_lenient_rejects_non_alphabet_chars`.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
## System Overview
|
||||||
|
|
||||||
@@ -121,6 +123,157 @@ master_key ────────►│ XChaCha20 │──────
|
|||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Org-key ECIES wrap/unwrap
|
||||||
|
|
||||||
|
Org vaults use a different key-derivation path than personal vaults. There is no
|
||||||
|
passphrase, no reference JPEG, and no Argon2id involved. Instead, each org has a
|
||||||
|
single random **org master key** that is wrapped per-member using X25519 ECIES and
|
||||||
|
stored as an opaque blob in `keys/<member-id>.enc` inside the org repo.
|
||||||
|
|
||||||
|
### Org master key
|
||||||
|
|
||||||
|
```
|
||||||
|
generate_org_key() (org.rs:230)
|
||||||
|
→ OsRng → 256-bit random
|
||||||
|
→ Zeroizing<[u8; 32]> (held in memory; never written in the clear)
|
||||||
|
```
|
||||||
|
|
||||||
|
One org key per org. It is re-generated on every `org rotate-key` operation.
|
||||||
|
|
||||||
|
### ed25519 → X25519 conversion
|
||||||
|
|
||||||
|
Each Relicario device holds an ed25519 signing key. To participate in ECIES the
|
||||||
|
ed25519 key pair must be mapped to X25519:
|
||||||
|
|
||||||
|
```
|
||||||
|
Recipient public key (for wrap):
|
||||||
|
ed25519 VerifyingKey
|
||||||
|
→ .to_montgomery() (birational Montgomery map, ed25519_dalek)
|
||||||
|
→ X25519 PublicKey
|
||||||
|
|
||||||
|
Recipient secret key (for unwrap):
|
||||||
|
ed25519 seed (32 bytes)
|
||||||
|
→ SHA-512(seed)[..32] (org.rs:241–242)
|
||||||
|
→ RFC 7748 clamp:
|
||||||
|
scalar[0] &= 248
|
||||||
|
scalar[31] &= 127
|
||||||
|
scalar[31] |= 64
|
||||||
|
→ x25519_dalek::StaticSecret
|
||||||
|
```
|
||||||
|
|
||||||
|
The RFC 7748 clamp and the `to_montgomery()` birational map are the standard
|
||||||
|
construction; a pinned RFC 8032 known-answer vector is verified in the unit tests
|
||||||
|
inside `org.rs`.
|
||||||
|
|
||||||
|
### Wrap flow (one blob per member)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ wrap_org_key() │ (org.rs:265)
|
||||||
|
│ │
|
||||||
|
org_key ──────────►│ EphemeralSecret::random (OsRng) │
|
||||||
|
│ ephemeral_pk = PublicKey::from(eph) │
|
||||||
|
│ │
|
||||||
|
recipient_pk ─────►│ DH: eph_sk.diffie_hellman(rec_pk) │
|
||||||
|
│ → dh_shared (32 bytes) │
|
||||||
|
│ │
|
||||||
|
│ kdf_input = dh_shared │
|
||||||
|
│ ‖ ephemeral_pk (32 B) │ (org.rs:278–281)
|
||||||
|
│ ‖ recipient_pk (32 B) │
|
||||||
|
│ wrap_key = SHA-256(kdf_input) │
|
||||||
|
│ (kdf_input in Zeroizing<Vec<u8>>) │
|
||||||
|
│ (wrap_key in Zeroizing<[u8;32]>) │
|
||||||
|
│ │
|
||||||
|
│ encrypted = crate::crypto::encrypt │
|
||||||
|
│ (wrap_key, org_key) │
|
||||||
|
│ → version(1) ‖ nonce(24) ‖ ct+tag │
|
||||||
|
│ │
|
||||||
|
│ output: ephemeral_pk(32) │ (org.rs:264)
|
||||||
|
│ ‖ version(1) │
|
||||||
|
│ ‖ nonce(24) │
|
||||||
|
│ ‖ ciphertext + tag │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
keys/<member-id>.enc (in org repo)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unwrap flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ unwrap_org_key() │ (org.rs:299)
|
||||||
|
│ │
|
||||||
|
wrapped blob ─────►│ split: ephemeral_pk(32) + rest │
|
||||||
|
│ │
|
||||||
|
ed25519_seed ─────►│ ed25519_seed_to_x25519_secret() │
|
||||||
|
│ → recipient_sk + recipient_pk │
|
||||||
|
│ │
|
||||||
|
│ DH: recipient_sk.diffie_hellman(eph)│
|
||||||
|
│ → dh_shared │
|
||||||
|
│ │
|
||||||
|
│ kdf_input + SHA-256 → wrap_key │
|
||||||
|
│ (same domain-separated KDF as wrap) │
|
||||||
|
│ │
|
||||||
|
│ plaintext = crate::crypto::decrypt │
|
||||||
|
│ (wrap_key, rest) │
|
||||||
|
│ → Zeroizing<[u8;32]> org_key │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key distinction: no Argon2id
|
||||||
|
|
||||||
|
Unlike the personal vault, **org crypto bypasses Argon2id entirely**:
|
||||||
|
|
||||||
|
| | Personal vault | Org vault |
|
||||||
|
|---|---|---|
|
||||||
|
| Key origin | Argon2id(passphrase ‖ image_secret, salt) | OsRng → 256-bit random |
|
||||||
|
| Key transport | Embedded in reference JPEG (stego) | X25519 ECIES wrap blob |
|
||||||
|
| AEAD primitive | XChaCha20-Poly1305 (`crate::crypto::encrypt`) | Same primitive (delegated) |
|
||||||
|
| KDF for wrap key | Argon2id | SHA-256(DH ‖ eph_pk ‖ rec_pk) |
|
||||||
|
|
||||||
|
The inner AEAD (`crate::crypto::encrypt` / `decrypt`) is **not re-implemented** in
|
||||||
|
the org module — it is called directly, so org item blobs share the identical
|
||||||
|
`version(1) ‖ nonce(24) ‖ ct+tag` wire format (`VERSION_BYTE = 0x02`,
|
||||||
|
`crates/relicario-core/src/crypto.rs:59`).
|
||||||
|
|
||||||
|
### Zeroize discipline
|
||||||
|
|
||||||
|
All intermediates that carry key material are dropped through `Zeroizing`:
|
||||||
|
|
||||||
|
- `org_key` — `Zeroizing<[u8; 32]>` everywhere it is passed
|
||||||
|
- `kdf_input` — `Zeroizing<Vec<u8>>` (org.rs:278)
|
||||||
|
- `wrap_key` — `Zeroizing<[u8; 32]>`
|
||||||
|
- decrypt `plaintext` in `unwrap_org_key` — `Zeroizing<Vec<u8>>`
|
||||||
|
|
||||||
|
### Key rotation and re-encryption
|
||||||
|
|
||||||
|
`org rotate-key` (`crates/relicario-cli/src/commands/org.rs:332`) does more than
|
||||||
|
generate a fresh org key:
|
||||||
|
|
||||||
|
```
|
||||||
|
run_rotate_key()
|
||||||
|
1. git pull --rebase (detect concurrent rotation → abort if non-fast-forward)
|
||||||
|
2. generate_org_key() → new_org_key
|
||||||
|
3. wrap_org_key(new_org_key, member_pk) for every current member
|
||||||
|
→ overwrites keys/<member-id>.enc
|
||||||
|
4. re-encrypt every items/<slug>/<id>.enc blob under new_org_key
|
||||||
|
5. re-encrypt manifest.enc under new_org_key
|
||||||
|
6. git add + git commit via org_git_run (signed; Relicario-Action: key-rotate)
|
||||||
|
```
|
||||||
|
|
||||||
|
`rotate-key` pulls (`--rebase`) at the start to pick up concurrent changes and
|
||||||
|
abort on a conflicting concurrent rotation, then commits locally; it does **not**
|
||||||
|
push. Publishing the rotation to the remote is a separate step (the normal git
|
||||||
|
sync path), the same way personal-vault mutations commit locally and sync later.
|
||||||
|
|
||||||
|
Re-encryption of every item blob (step 4) is deliberate: a removed member who holds
|
||||||
|
a local clone of the repo cannot decrypt any item written after the rotation, because
|
||||||
|
those blobs are sealed under a key they never received. Without re-encryption, all
|
||||||
|
pre-rotation blobs would remain readable to the former member indefinitely.
|
||||||
|
|
||||||
|
The item-CRUD commands (`org add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`) that read and write these blobs are merged and wired into `main.rs`; each operates under the org master key recovered by `unwrap_org_key`.
|
||||||
|
|
||||||
## imgsecret DCT Embedding
|
## imgsecret DCT Embedding
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -161,11 +314,14 @@ master_key ────────►│ XChaCha20 │──────
|
|||||||
│ selected block: │
|
│ selected block: │
|
||||||
│ │
|
│ │
|
||||||
│ QIM embed bits │
|
│ QIM embed bits │
|
||||||
│ in positions │
|
│ in zig-zag │
|
||||||
│ 4-15 (mid-freq) │
|
│ positions 6-17 │
|
||||||
|
│ (mid-frequency) │
|
||||||
│ │
|
│ │
|
||||||
│ Repeat secret │
|
│ Repeat secret │
|
||||||
│ 20+ times │
|
│ MIN_COPIES (5) │
|
||||||
|
│ to 50 times, │
|
||||||
|
│ by capacity │
|
||||||
└────────┬─────────┘
|
└────────┬─────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -181,6 +337,8 @@ master_key ────────►│ XChaCha20 │──────
|
|||||||
carries 256-bit secret)
|
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)
|
## Extraction (with crop recovery)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -214,10 +372,12 @@ Input JPEG (possibly re-encoded or cropped)
|
|||||||
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
|
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
|
||||||
│ version │ nonce │ ciphertext │ auth tag │
|
│ version │ nonce │ ciphertext │ auth tag │
|
||||||
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
|
│ 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
|
## Crate Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -267,3 +427,7 @@ Stolen device: ████░░░░░░░░░░░░░
|
|||||||
|
|
||||||
Both factors compromised: game over (same as every password manager)
|
Both factors compromised: game over (same as every password manager)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next:** [FORMATS.md](FORMATS.md) — the byte-level wire formats.
|
||||||
169
docs/FORMATS.md
Normal file
169
docs/FORMATS.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## Org vault repo formats
|
||||||
|
|
||||||
|
The org vault is a **separate git repository** alongside the personal vault. It is not nested inside `.relicario/`. Its layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
org.json # OrgMeta (schema_version, org_id, display_name, created_at)
|
||||||
|
members.json # PUBLIC/unencrypted member directory
|
||||||
|
collections.json # collection definitions
|
||||||
|
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||||||
|
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||||
|
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||||||
|
attachments/<collection-slug>/<item-id>/<att-id>.enc # Document attachment blobs (collection-scoped)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `org.json` — OrgMeta
|
||||||
|
|
||||||
|
Unencrypted JSON (`OrgMeta`, `org.rs:164`). `schema_version: 1` (`org.rs:174`). Fields: `schema_version`, `org_id`, `display_name`, `created_at` (Unix seconds).
|
||||||
|
|
||||||
|
### `members.json` — OrgMembers
|
||||||
|
|
||||||
|
Unencrypted JSON array of `OrgMember` records (`org.rs:72`); container type `OrgMembers` carries `schema_version: 1` (`org.rs:93`). Per-member fields: `member_id` (16 lowercase hex chars), `display_name`, `role` (one of `owner | admin | member`), `ed25519_pubkey` (OpenSSH wire string), `collections` (array of granted slug strings), `added_at`, `added_by`. Roles are not secrets — authorization to read this file is not required to verify signatures.
|
||||||
|
|
||||||
|
### `collections.json` — OrgCollections
|
||||||
|
|
||||||
|
Unencrypted JSON; `schema_version: 1` (`org.rs:138`). Contains a list of `CollectionDef` records (`org.rs:123`). Validation (`org.rs:145`) rejects slugs that are empty, contain `/`, or equal `.`.
|
||||||
|
|
||||||
|
### `keys/<member-id>.enc` — wrapped org master key
|
||||||
|
|
||||||
|
Binary blob; NOT a standard `.enc` blob. Layout (`org.rs:264`):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┬─────────┬────────┬──────────────────────┐
|
||||||
|
│ ephemeral_x25519_pubkey │ version │ nonce │ ciphertext + tag │
|
||||||
|
│ 32 bytes │ 1 byte │24 bytes│ N + 16 bytes │
|
||||||
|
└──────────────────────────┴─────────┴────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)` (`org.rs:278–281`), held in `Zeroizing<Vec<u8>>`.
|
||||||
|
- The inner AEAD (`version || nonce || ciphertext+tag`) is produced by `crate::crypto::encrypt` — the same XChaCha20-Poly1305 framing used for personal `.enc` blobs (see **Encrypted blob** above). `VERSION_BYTE = 0x02` applies here too.
|
||||||
|
- The X25519 private scalar is derived from the device ed25519 seed via `SHA-512(seed)[..32]` with RFC 7748 clamping (`org.rs:242`). Argon2id is **not** involved — the wrapping key is derived entirely from the X25519 DH exchange.
|
||||||
|
|
||||||
|
### `manifest.enc` — OrgManifest
|
||||||
|
|
||||||
|
Encrypted with the org master key using `crypto::encrypt` (standard `.enc` framing). Decrypts to `OrgManifest` JSON (`org.rs:199`); `schema_version: 1` (`org.rs:206`). Each `OrgManifestEntry` (`org.rs:185`) carries: `id`, `type`, `title`, `tags`, `modified`, `trashed_at`, and a `collection` slug field. The `collection` field distinguishes this type from `ManifestEntry` in the personal vault.
|
||||||
|
|
||||||
|
Contrast with the personal vault manifest: `Manifest` uses `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) and `ManifestEntry` has no `collection` field. The two types are distinct and do not share a schema.
|
||||||
|
|
||||||
|
### `items/<collection-slug>/<item-id>.enc`
|
||||||
|
|
||||||
|
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key. The blob itself does **not** name its collection — the directory path segment carries the slug. This allows the pre-receive hook (`relicario-server`) to authorize a write by path segment without decrypting the blob.
|
||||||
|
|
||||||
|
These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present.
|
||||||
|
|
||||||
|
### `attachments/<collection-slug>/<item-id>/<att-id>.enc`
|
||||||
|
|
||||||
|
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key — the encrypted file payload of a Document item. As with item blobs, the blob does **not** name its collection; the leading `<collection-slug>` path segment carries it, so the pre-receive hook (`relicario-server`, `classify_path`) authorizes the write by slug without decrypting — reusing the same grant + slug-existence check as the `items/` branch. The path is **exactly three segments** after `attachments/` (`<collection-slug>/<item-id>/<att-id>.enc`); the hook rejects any other shape (segment-count and `.`-free slug guards). `<att-id>` is the content-addressed `AttachmentId` (see **Item IDs and Field IDs** below).
|
||||||
|
|
||||||
|
Per-attachment size is capped at `DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024` (10 MiB) (`org_session.rs:24`), mirroring the personal-vault default `AttachmentCaps::per_attachment_max_bytes` (`settings.rs:116`). Org vaults have no `settings.enc`, so this cap is a fixed default rather than per-org configurable. Blobs are persisted / read / removed by `UnlockedOrgVault::save_attachment` / `load_attachment` / `remove_item_attachments` (`org_session.rs:137`, `:147`, `:156`). The storage primitives back the org **Document** item type; the `org add document` / Document-edit commands that produce these blobs land in v0.8.1 (see the item-type-parity note below).
|
||||||
|
|
||||||
|
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types (landing in v0.8.1; Document file payloads use the attachment layout above).
|
||||||
|
|
||||||
|
## 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.
|
||||||
133
docs/SECURITY.md
133
docs/SECURITY.md
@@ -1,5 +1,7 @@
|
|||||||
# Relicario Security Model
|
# 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
|
## Cryptographic Protection
|
||||||
|
|
||||||
Relicario uses two-factor vault decryption:
|
Relicario uses two-factor vault decryption:
|
||||||
@@ -72,6 +74,133 @@ Without device authentication, access control is transport-layer only:
|
|||||||
|
|
||||||
Device registration is optional but recommended for shared vaults.
|
Device registration is optional but recommended for shared vaults.
|
||||||
|
|
||||||
|
## Org vault security
|
||||||
|
|
||||||
|
An org vault is a separate git repository alongside the personal vault. It
|
||||||
|
uses ed25519 commit-signing and a server-side pre-receive hook to make
|
||||||
|
least-privilege access control server-enforced, not advisory.
|
||||||
|
|
||||||
|
### Org device-key authentication
|
||||||
|
|
||||||
|
Every org member registers an ed25519 device key. The key appears in
|
||||||
|
`members.json` as an OpenSSH public-key string alongside the member's role
|
||||||
|
and collection grants. Fingerprint matching is done via
|
||||||
|
`relicario_core::fingerprint`, which normalises the OpenSSH format so that
|
||||||
|
whitespace and comment differences do not create phantom mismatches.
|
||||||
|
|
||||||
|
Org access requires two things at once: a wrapped key blob (`keys/<member-id>.enc`)
|
||||||
|
and the device private key that can unwrap it. There is no org passphrase —
|
||||||
|
removing a member's blob and rotating the org master key is sufficient to
|
||||||
|
revoke access (see **Key rotation** below). Device keys are completely
|
||||||
|
separate from the personal vault's KDF inputs; revoking org access does not
|
||||||
|
affect the member's personal vault.
|
||||||
|
|
||||||
|
### Pre-receive hook enforcement
|
||||||
|
|
||||||
|
`relicario-server generate-org-hook` (`crates/relicario-server/src/main.rs:511`)
|
||||||
|
emits a hook script that calls `relicario-server verify-org-commit` for
|
||||||
|
every pushed commit. Unsigned or structurally invalid commits are rejected
|
||||||
|
before they land.
|
||||||
|
|
||||||
|
`verify_org_commit` (`main.rs:286`) performs four checks in order:
|
||||||
|
|
||||||
|
1. **Signature verification** — a temporary `allowed_signers` file is
|
||||||
|
constructed from the current `members.json`; `git verify-commit --raw`
|
||||||
|
is run and the resulting SHA-256 fingerprint is matched back to a
|
||||||
|
`members.json` entry. A commit not signed by a *current* member is
|
||||||
|
rejected outright.
|
||||||
|
|
||||||
|
2. **Path-level write authorisation** — each modified path is classified by
|
||||||
|
`classify_path` (`crates/relicario-server/src/lib.rs:20`) into
|
||||||
|
`Protected` (owner/admin write only), `Item { collection }` (the
|
||||||
|
`items/<slug>/…` or `attachments/<slug>/…` prefix; write allowed only if
|
||||||
|
the slug appears in the signer's `collections` grant array), or
|
||||||
|
`Unrestricted`. The write is
|
||||||
|
authorised if and only if the signer's role and grants satisfy the
|
||||||
|
classification. Item blobs are authorised by the leading path segment
|
||||||
|
alone — the ciphertext is never decrypted by the hook.
|
||||||
|
|
||||||
|
3. **Owner-only elevation guard** (`enforce_owner_only_elevation`,
|
||||||
|
`main.rs:438`) — only a member whose *pre-commit* (parent) role is Owner
|
||||||
|
may introduce a new member at Owner or Admin level, or promote an
|
||||||
|
existing member to either. Checking the pre-commit role means an Admin
|
||||||
|
cannot self-promote in the same commit that writes the escalated
|
||||||
|
`members.json`; there is no epoch in which the transition is
|
||||||
|
self-authorised.
|
||||||
|
|
||||||
|
4. **Schema monotonicity** (`enforce_schema_monotonicity`, `main.rs:521`)
|
||||||
|
— `schema_version` values in org JSON containers may not decrease.
|
||||||
|
Merge commits are rejected. A genesis commit (no parents) is allowed
|
||||||
|
only when it is signed by the sole Owner it introduces.
|
||||||
|
|
||||||
|
#### Attachment write authorisation (v0.1.1 fix)
|
||||||
|
|
||||||
|
Prior to `relicario-server` v0.1.1, `attachments/…` paths fell through to
|
||||||
|
`PathClass::Unrestricted` in `classify_path`
|
||||||
|
(`crates/relicario-server/src/lib.rs:20`). Any member with push access could
|
||||||
|
write attachment blobs to any collection regardless of their grants. As of
|
||||||
|
v0.1.1, `attachments/<slug>/<item-id>/<att-id>.enc` is classified as
|
||||||
|
`PathClass::Item { collection: slug }`, bringing attachment writes under the
|
||||||
|
same grant check already applied to `items/<slug>/<id>.enc` blobs.
|
||||||
|
|
||||||
|
**Deploying this fix requires rebuilding and redeploying the pre-receive hook
|
||||||
|
on the server.** A server still running a hook built before v0.1.1 continues
|
||||||
|
to accept attachment pushes from any member; the `Unrestricted` path is only
|
||||||
|
closed once the updated hook is installed at `<repo>/hooks/pre-receive`.
|
||||||
|
|
||||||
|
### Key rotation
|
||||||
|
|
||||||
|
`relicario org rotate-key` generates a fresh 256-bit org master key,
|
||||||
|
re-wraps it for every current member, and re-encrypts every
|
||||||
|
`items/<slug>/<id>.enc` blob and the manifest under the new key in a single
|
||||||
|
signed commit tagged `Relicario-Action: key-rotate`. A revoked member's
|
||||||
|
wrapped blob is simply not written during rotation, so they hold a blob that
|
||||||
|
decrypts to a stale key — they cannot read items encrypted under the new
|
||||||
|
key.
|
||||||
|
|
||||||
|
### Audit action vocabulary
|
||||||
|
|
||||||
|
The `relicario org audit` command attributes actions to their verified
|
||||||
|
signer (not to the commit author or trailer value). Each event records two
|
||||||
|
actors: the **verified** actor resolved from the signing key (authoritative)
|
||||||
|
and the actor **claimed** by the `Relicario-Actor` trailer (advisory). When the
|
||||||
|
claimed actor disagrees with the verified signer, the event is flagged
|
||||||
|
`TAMPERED`. Trailers are advisory metadata; the trustworthy actor is always
|
||||||
|
the cryptographically verified signer.
|
||||||
|
|
||||||
|
Actions live in two groups:
|
||||||
|
|
||||||
|
- **Membership / collections / lifecycle:** `member-add`, `member-remove`,
|
||||||
|
`member-role-change`, `collection-create`, `collection-grant`,
|
||||||
|
`collection-revoke`, `key-rotate`, `org-init`, `ownership-transfer`,
|
||||||
|
`org-delete`.
|
||||||
|
- **Item CRUD:** `item-create`, `item-update`, `item-delete` (soft-delete /
|
||||||
|
trash), `item-restore`, `item-purge` — emitted by the `org add` / `edit` /
|
||||||
|
`rm` / `restore` / `purge` commands.
|
||||||
|
|
||||||
|
### Honest limitations
|
||||||
|
|
||||||
|
The following are deliberate design boundaries, not oversights:
|
||||||
|
|
||||||
|
- **Shared org master key — reads are not cryptographically scoped per
|
||||||
|
collection.** The pre-receive hook scopes *writes* by collection path
|
||||||
|
and the CLI filters the manifest to each member's grants, but a single
|
||||||
|
org key opens all collection blobs. A member with any grant can, outside
|
||||||
|
the CLI, decrypt items from collections they are not granted. For true
|
||||||
|
cryptographic separation, use a separate org vault per access boundary.
|
||||||
|
Per-collection subkeys are a phase-2 non-goal.
|
||||||
|
|
||||||
|
- **No read audit.** Git records writes only. A member who reads blobs
|
||||||
|
directly leaves no server-visible trace.
|
||||||
|
|
||||||
|
- **No "hide value."** There is no mechanism to show a member that an item
|
||||||
|
exists without revealing its field values on decrypt.
|
||||||
|
|
||||||
|
- **`delete-org` is a local tombstone in phase 1.** The schema-monotonicity
|
||||||
|
check causes the hook to reject protected-file deletion, so an
|
||||||
|
`org-delete` action cannot be pushed to a hook-protected remote. The
|
||||||
|
deletion is recorded locally only until a future phase addresses it.
|
||||||
|
|
||||||
## Configuration env vars
|
## Configuration env vars
|
||||||
|
|
||||||
Relicario reads the following environment variables. Each is a trust
|
Relicario reads the following environment variables. Each is a trust
|
||||||
@@ -102,3 +231,7 @@ standard `--release` profile).
|
|||||||
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
| `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_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. |
|
| `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.
|
||||||
|
|||||||
161
docs/superpowers/RELEASE-WORKFLOW.md
Normal file
161
docs/superpowers/RELEASE-WORKFLOW.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Release Workflow
|
||||||
|
|
||||||
|
Unified lifecycle workflow at `.claude/workflows/release.js`.
|
||||||
|
Invoke from any Claude Code session via the `Workflow` tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actions at a glance
|
||||||
|
|
||||||
|
| Action | When | Mode |
|
||||||
|
|--------|------|------|
|
||||||
|
| `develop` | Implement plan tasks | `single` (phone/remote) or `multi` (PC, supervised) |
|
||||||
|
| `verify` | Check tests pass | — |
|
||||||
|
| `debug` | Fix a failing test or broken feature | — (always sequential) |
|
||||||
|
| `release` | Cut and tag a version | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add features / implement a plan
|
||||||
|
|
||||||
|
### Single-agent (phone-friendly, fire-and-forget)
|
||||||
|
|
||||||
|
One agent works through all plan tasks sequentially. Kick off and check the progress tree later.
|
||||||
|
|
||||||
|
```js
|
||||||
|
Workflow({
|
||||||
|
name: 'release',
|
||||||
|
args: { action: 'develop', mode: 'single', release: 'v0.5.0' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
1. Discovers all plan files matching `v0.5.0`
|
||||||
|
2. PM agent reads plans, orders tasks respecting dependencies
|
||||||
|
3. One dev agent per task runs sequentially
|
||||||
|
4. Full `cargo test` + `cargo build` + `cargo clippy` verify pass
|
||||||
|
5. Updates `STATUS.md`
|
||||||
|
|
||||||
|
### Multi-agent (PC, supervised by PM)
|
||||||
|
|
||||||
|
PM reads the plans, decides N dev streams, writes kickoff prompt files. You open the terminals.
|
||||||
|
|
||||||
|
```js
|
||||||
|
Workflow({
|
||||||
|
name: 'release',
|
||||||
|
args: { action: 'develop', mode: 'multi', release: 'v0.5.0' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
1. Discovers plans
|
||||||
|
2. PM agent assigns tasks to N dev streams
|
||||||
|
3. Generates PM + N dev prompt files in `docs/superpowers/coordination/`
|
||||||
|
4. Prints terminal-open instructions
|
||||||
|
|
||||||
|
**Then you:**
|
||||||
|
```bash
|
||||||
|
cd tools/relay && ./start.sh # start relay server
|
||||||
|
# open N+1 terminal windows
|
||||||
|
# PM window: paste coordination/v0.5.0-pm-prompt.md
|
||||||
|
# Dev-A window: paste coordination/v0.5.0-dev-a-prompt.md
|
||||||
|
# Dev-B window: paste coordination/v0.5.0-dev-b-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The PM supervises devs in real time via the relay. You watch all terminals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run tests only
|
||||||
|
|
||||||
|
```js
|
||||||
|
Workflow({ name: 'release', args: { action: 'verify' } })
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs `cargo test`, `cargo build --all-targets`, `cargo clippy`. Returns pass/fail summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug iteration
|
||||||
|
|
||||||
|
After you find a broken test or unexpected behavior, hand the failure context to the debug action. It loops up to 5 times: hypothesize → read code → fix → verify → commit.
|
||||||
|
|
||||||
|
```js
|
||||||
|
Workflow({
|
||||||
|
name: 'release',
|
||||||
|
args: {
|
||||||
|
action: 'debug',
|
||||||
|
context: 'cargo test output:\n...<paste failing test output here>...'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ status: "fixed", iterations: N }` when clean, or `{ status: "max-iterations" }` if it needs your eyes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cut a release
|
||||||
|
|
||||||
|
Runs verify first; blocked if tests fail.
|
||||||
|
Writes CHANGELOG, updates STATUS + ROADMAP, creates annotated tag.
|
||||||
|
**Stops before pushing** — you confirm manually.
|
||||||
|
|
||||||
|
```js
|
||||||
|
Workflow({
|
||||||
|
name: 'release',
|
||||||
|
args: { action: 'release', release: 'v0.5.0' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
After it stops, review the tag then:
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full lifecycle example
|
||||||
|
|
||||||
|
```
|
||||||
|
1. DEVELOP features
|
||||||
|
Workflow({ name:"release", args:{ action:"develop", mode:"single", release:"v0.6.0" } })
|
||||||
|
|
||||||
|
2. VERIFY manually (you run the extension in browser, test your flows)
|
||||||
|
|
||||||
|
3. DEBUG any failures you find
|
||||||
|
Workflow({ name:"release", args:{ action:"debug", context:"<paste failure>" } })
|
||||||
|
# repeat as needed
|
||||||
|
|
||||||
|
4. VERIFY again to confirm clean
|
||||||
|
Workflow({ name:"release", args:{ action:"verify" } })
|
||||||
|
|
||||||
|
5. RELEASE
|
||||||
|
Workflow({ name:"release", args:{ action:"release", release:"v0.6.0" } })
|
||||||
|
# review tag, then: git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phone vs PC
|
||||||
|
|
||||||
|
| Scenario | Recipe |
|
||||||
|
|----------|--------|
|
||||||
|
| Kick off a release from your phone / remote session | `develop` + `mode:"single"` — fires in background, check `/workflows` |
|
||||||
|
| At your PC, want to supervise and intervene | `develop` + `mode:"multi"` — generates prompts, open terminals |
|
||||||
|
| Quick sanity check | `verify` |
|
||||||
|
| Fixing a bug you found while testing | `debug` with failure context |
|
||||||
|
| Cutting and tagging | `release` — always confirms before push |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan file discovery
|
||||||
|
|
||||||
|
The `develop` action scans `docs/superpowers/plans/` for files whose filename or opening lines reference the release label. To be explicit, pass plan paths directly (not yet wired — add `args.plans` if needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relay server roles
|
||||||
|
|
||||||
|
The relay at `localhost:7331` supports roles: `pm`, `dev-a`, `dev-b`, `dev-c`.
|
||||||
|
Start it before opening terminal sessions: `cd tools/relay && ./start.sh`
|
||||||
|
See `docs/superpowers/coordination/RELAY.md` for protocol details.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# CLI Tail — Cycle 2 Coordinator
|
||||||
|
|
||||||
|
**Date:** 2026-05-09
|
||||||
|
**Status:** Draft (launches once cycle-1 prerequisites land)
|
||||||
|
**Theme:** parallelize the post-split tail of Plan B (the CLI restructure) across three independent streams. Plan B's eight phases are already defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`; this coordinator only partitions the remaining phases across cycle-2 streams and records the cross-stream contracts.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
The cycle-1 four-agent run (`2026-05-04-arch-followup-*`) ships:
|
||||||
|
|
||||||
|
- **Stream A** — Plan A (security + docs polish): `impl Drop for SessionHandle`, JS swallow removal, `recovery_qr.rs` docs, `start.sh` fourth-window. Independent of B and C.
|
||||||
|
- **Stream B** — Plan B Phases 1 + 2 only (mechanical `main.rs` split + `helpers::git_run` + 16-site sweep). Stops after Phase 2 per a 2026-05-09 user-driven RESCOPE directive.
|
||||||
|
- **Stream C** — Plan C (extension restructure). Did not launch in cycle 1 (DEV-C never acked); remains pending and is *not* picked up by cycle 2 (still its own multi-week effort, separate kickoff).
|
||||||
|
|
||||||
|
The remaining six Plan B phases (3 through 8) are partitioned across three cycle-2 streams below. Each cycle-2 stream is independent of the other two once cycle-1 Stream B (Phase 1 + 2) has merged to `main`.
|
||||||
|
|
||||||
|
## Pre-launch checklist (cycle 2 cannot open until all green)
|
||||||
|
|
||||||
|
- [ ] Cycle-1 Stream A merged to `main`
|
||||||
|
- [ ] Cycle-1 Stream B PR (Phase 1 + 2 bundle) merged to `main`
|
||||||
|
- [ ] Working tree clean on `main`; `git pull` reflects both merges
|
||||||
|
- [ ] All cycle-1 worktrees torn down (`git worktree remove ../relicario.arch-followup-stream-a` and `*-stream-b`); cycle-1 branches deleted locally if requested
|
||||||
|
- [ ] Relay server still running on `localhost:7331` (check `ss -ltn 'sport = :7331'`)
|
||||||
|
- [ ] Cycle-2 kickoff prompts present in `docs/superpowers/coordination/2026-05-09-cli-tail-{pm,dev-a,dev-b,dev-c}-prompt.md`
|
||||||
|
|
||||||
|
## Stream partition
|
||||||
|
|
||||||
|
| Stream | Branch | Worktree | Plan B phases | Theme |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A | `feature/cli-tail-stream-a-prompt-helpers` | `/home/alee/Sources/relicario.cli-tail-stream-a` | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
|
||||||
|
| B | `feature/cli-tail-stream-b-session-manifest` | `/home/alee/Sources/relicario.cli-tail-stream-b` | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
|
||||||
|
| C | `feature/cli-tail-stream-c-core-wasm-seam` | `/home/alee/Sources/relicario.cli-tail-stream-c` | Phases 7, 8 | parser migration to `relicario-core` + base32 dedup + WASM exports |
|
||||||
|
|
||||||
|
Phases reference the canonical definitions in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`. Devs do NOT redesign — they execute against that spec.
|
||||||
|
|
||||||
|
## Cross-stream dependencies (cycle 2)
|
||||||
|
|
||||||
|
- **Stream A and Stream B**: both touch `crates/relicario-cli/src/commands/*.rs` files but in disjoint ways. Stream A modifies `commands/add.rs` (the seven `build_*_item` builders). Stream B modifies `commands/init.rs` (`ParamsFile`), `commands/trash.rs` (batched purge), and seven manifest-mutation sites scattered across `commands/{add,edit,trash,attach,settings,import}.rs`. Conflict surface is `commands/add.rs` (A modifies builders; B modifies the `after_manifest_change` callsite). Whoever opens their PR second rebases.
|
||||||
|
- **Stream B internal sequencing**: Phase 6 (batched purge) depends on Phase 4 (`after_manifest_change` wrapper) — Phase 6's commit message logic uses the wrapper. Phase 5 (`ParamsFile`) is independent of 4 and 6 within Stream B; can ship first, last, or middle.
|
||||||
|
- **Stream C**: touches `crates/relicario-core/`, `crates/relicario-wasm/`, and `extension/src/wasm.d.ts` only. Zero overlap with Streams A and B. Internal sequencing: Phase 7 (parser migration to core) before Phase 8 (WASM exports + `wasm.d.ts` mirror).
|
||||||
|
- **No cross-stream interface contracts.** All three plans were finalized in cycle 1; the partition does not introduce new contracts.
|
||||||
|
|
||||||
|
## Pre-merge checklist (per cycle-2 stream)
|
||||||
|
|
||||||
|
Same as cycle 1, plus a narration check:
|
||||||
|
|
||||||
|
- [ ] Stream's owned phases all complete per Plan B's "Done criteria"
|
||||||
|
- [ ] `cargo test --workspace` green on the stream's worktree
|
||||||
|
- [ ] `cargo clippy --workspace` silent
|
||||||
|
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, but Stream C in particular)
|
||||||
|
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` tests pass without modification
|
||||||
|
- [ ] Narration discipline observed — STATUS UPDATEs include in-flight beats, not just phase boundaries
|
||||||
|
- [ ] PR description cross-references the corresponding Plan B phase numbers
|
||||||
|
|
||||||
|
## Out of scope for cycle 2
|
||||||
|
|
||||||
|
- Plan C (extension restructure) — multi-week effort, scheduled separately when DEV-C bandwidth available
|
||||||
|
- The Plan B `helpers::git_run` itself (shipped in cycle 1 Stream B)
|
||||||
|
- The cycle-1 P3 nits explicitly out-of-scope in Plan B
|
||||||
|
- The eight "Open architectural decisions" from the synthesis
|
||||||
|
|
||||||
|
## Tag
|
||||||
|
|
||||||
|
No release tag for cycle 2. Same as the cycle-1 architecture-review followup train — these are structural-cleanup bundles, not versioned releases. Each stream merges via `gh pr merge --merge` (preserve history; no squash per project convention).
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Dev A Kickoff Prompt — CLI Tail (Cycle 2) Stream A
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Stream A of the CLI-tail cycle-2 release.
|
||||||
|
|
||||||
|
Stream A is **Plan B Phase 3** — `prompt_or_flag<T>` helper plus the seven `build_*_item` builder compression in the CLI. Single phase, S-M effort. The phase is defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression". Cycle 1 already shipped the mechanical `main.rs` split (Phase 1) and the `helpers::git_run` sweep (Phase 2), so the file tree under `crates/relicario-cli/src/commands/` and `prompt.rs` is in place — your job is to add the helper to `prompt.rs` and refactor the seven builders in `commands/add.rs`.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with Dev-B (session/manifest discipline — Phases 4, 5, 6) and Dev-C (parser migration + WASM seam — Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git worktree add ../relicario.cli-tail-stream-a -b feature/cli-tail-stream-a-prompt-helpers
|
||||||
|
cd ../relicario.cli-tail-stream-a
|
||||||
|
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-a
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-a`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-a` so subagents don't accidentally commit to main. This is non-negotiable.
|
||||||
|
|
||||||
|
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-a"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cycle-1 lessons baked in (read once):**
|
||||||
|
|
||||||
|
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||||
|
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phase 3 only
|
||||||
|
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (your scope is **Phase 3 only**; read the whole plan for context, but execute Phase 3)
|
||||||
|
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
|
||||||
|
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the `build_*_item` discussion (line-level context for the seven builders the synthesis abbreviates)
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development** (per `CLAUDE.md` memory default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per sub-step, two-stage review.
|
||||||
|
|
||||||
|
**Every subagent prompt MUST start with**:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.cli-tail-stream-a
|
||||||
|
```
|
||||||
|
|
||||||
|
…before any other instruction. Non-negotiable per project memory.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Plan B Phase 3 — adding `prompt_or_flag<T>` (and `prompt_or_flag_optional<T>`) to `crates/relicario-cli/src/prompt.rs`, then refactoring the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper. Per-type bodies should shrink by ~30%.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Phases 4, 5, 6 (Dev-B owns) — `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge
|
||||||
|
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
|
||||||
|
- Anything outside Plan B's Phase 3 definition. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- Do not change the CLI's external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||||
|
- Do not merge your branch to main. The PM owns merges.
|
||||||
|
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of four terminals. The user runs all four; the PM in another terminal coordinates you.
|
||||||
|
|
||||||
|
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||||
|
|
||||||
|
- When you dispatch a subagent (so the user sees what's running)
|
||||||
|
- When a subagent returns with a decision worth flagging (an unexpected finding, a trade-off taken, a surprise)
|
||||||
|
- When a sub-task completes (e.g. `prompt_or_flag` helper landed; first builder converted)
|
||||||
|
- When you change direction or hit something unexpected
|
||||||
|
- When you start a new sub-step
|
||||||
|
|
||||||
|
The `Notes` field should narrate WHAT happened and WHY — not just "Phase 3 done". Three sentences max. Examples of useful: "subagent reported `build_login_item` already takes Result-wrapped fields, so the conversion is just chain-flattening"; "found one builder uses `prompt_secret`, kept it on raw `prompt_secret` since `prompt_or_flag` doesn't handle the no-echo case." Examples of NOT useful: "builder converted" with no context; "tests pass" with no count.
|
||||||
|
|
||||||
|
Print every STATUS UPDATE locally before/after sending it so the user reads it in your own terminal.
|
||||||
|
|
||||||
|
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print here. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-A
|
||||||
|
Time: <iso8601>
|
||||||
|
Branch: feature/cli-tail-stream-a-prompt-helpers
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line of message>
|
||||||
|
Tests: <green | red (which failed) | N/A>
|
||||||
|
Notes: <WHAT and WHY — 3 sentences max>
|
||||||
|
```
|
||||||
|
|
||||||
|
**When you need PM input mid-task**: post via `post_message(kind="question")`:
|
||||||
|
|
||||||
|
```
|
||||||
|
## QUESTION TO PM — DEV-A
|
||||||
|
Time: <iso8601>
|
||||||
|
Context: <what task, what decision point>
|
||||||
|
Options: <A: ... / B: ... / C: ...>
|
||||||
|
Recommended: <your pick + one-sentence rationale>
|
||||||
|
Blocker: yes | no
|
||||||
|
```
|
||||||
|
|
||||||
|
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM.
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||||
|
|
||||||
|
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||||
|
|
||||||
|
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||||
|
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||||
|
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||||
|
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||||
|
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
## Authority within Phase 3
|
||||||
|
|
||||||
|
You don't need PM permission to:
|
||||||
|
|
||||||
|
- Execute sub-steps per Plan B's Phase 3
|
||||||
|
- Make implementation decisions consistent with Plan B
|
||||||
|
- Write tests, refactor your own code, fix bugs you introduce
|
||||||
|
- Push commits to your feature branch
|
||||||
|
|
||||||
|
You **do** escalate when:
|
||||||
|
|
||||||
|
- A scope question outside Plan B Phase 3
|
||||||
|
- A test you can't make green after honest debugging
|
||||||
|
- A discovered bug not in Plan B
|
||||||
|
- Anything destructive (per `CLAUDE.md`)
|
||||||
|
- Before opening the PR for review
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run the project's full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario.cli-tail-stream-a
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace
|
||||||
|
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
All three must be green / clean. Then push and open the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/cli-tail-stream-a-prompt-helpers
|
||||||
|
gh pr create --base main --head feature/cli-tail-stream-a-prompt-helpers --title "refactor(cli): prompt_or_flag helper + build_*_item compression (Plan B Phase 3)" --body "$(cat <<'EOF'
|
||||||
|
## Summary
|
||||||
|
- Adds `prompt_or_flag<T>` and `prompt_or_flag_optional<T>` to `crates/relicario-cli/src/prompt.rs`
|
||||||
|
- Refactors the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper
|
||||||
|
- Per-type bodies shrink by ~30%; existing CLI integration tests pass without modification
|
||||||
|
|
||||||
|
## Plan B Phase 3
|
||||||
|
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phase 3.
|
||||||
|
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
- [x] cargo test --workspace
|
||||||
|
- [x] cargo clippy --workspace
|
||||||
|
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||||
|
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-a-prompt-helpers`), then start Phase 3 sub-step 1 (add `prompt_or_flag<T>` to `prompt.rs`).
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Dev B Kickoff Prompt — CLI Tail (Cycle 2) Stream B
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Stream B of the CLI-tail cycle-2 release.
|
||||||
|
|
||||||
|
Stream B is **Plan B Phases 4, 5, 6** — session/manifest discipline. Three phases, S-M effort each, total mid-day to multi-day:
|
||||||
|
|
||||||
|
- **Phase 4** — `Vault::after_manifest_change(&self, manifest: &Manifest)` wrapper that funnels the seven manifest-mutation sites in `commands/{add,edit,trash,attach,settings,import}.rs` through one `save_manifest + groups-cache write` path. Marks `save_manifest` as `pub(crate)` (or renames it `save_manifest_raw`) so callers must use the wrapper.
|
||||||
|
- **Phase 5** — Single canonical `ParamsFile` in `crates/relicario-cli/src/session.rs`, replacing the two-definition split between `commands/init.rs` (write side) and `session.rs:114` (read side). Adds `Serialize` + `Deserialize`, `for_new_vault` constructor, `into_kdf_params` inversion. On-disk JSON format must round-trip with current `params.json` files.
|
||||||
|
- **Phase 6** — Batched purge in `cmd_purge` and `cmd_trash_empty`. Renames `purge_item` to `purge_item_filesystem` (filesystem mutation only); the callers accumulate paths and run a single `git_run(...["rm", "-rf", "--ignore-unmatch", paths...])` plus `git_run(...["add", "manifest.enc"])` plus one `git_run(...["commit"])` per batch. A 50-item `trash empty` should fire 3 git invocations total, not 150.
|
||||||
|
|
||||||
|
The phases are defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 4", "Phase 5", "Phase 6". Internal sequencing: Phase 4 before Phase 6 (Phase 6 uses `after_manifest_change`); Phase 5 is independent of 4 and 6.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-C (Plan B Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git worktree add ../relicario.cli-tail-stream-b -b feature/cli-tail-stream-b-session-manifest
|
||||||
|
cd ../relicario.cli-tail-stream-b
|
||||||
|
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-b
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-b`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-b` so subagents don't accidentally commit to main. Non-negotiable.
|
||||||
|
|
||||||
|
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-b"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cycle-1 lessons baked in (read once):**
|
||||||
|
|
||||||
|
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||||
|
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 4, 5, 6 only
|
||||||
|
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 4, 5, 6)
|
||||||
|
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only)
|
||||||
|
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes; the relevant sections are `refresh_groups_cache` discipline, `ParamsFile` dedup, batched purge
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
|
||||||
|
|
||||||
|
**Every subagent prompt MUST start with**:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.cli-tail-stream-b
|
||||||
|
```
|
||||||
|
|
||||||
|
…before any other instruction.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Plan B Phases 4, 5, 6.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + `build_*_item` compression
|
||||||
|
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
|
||||||
|
- Anything outside Plan B Phases 4-6. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- Phase 5 must round-trip with existing on-disk `params.json` — write a fixture-string test that reads a known-current params.json and asserts the canonical struct parses it identically. On-disk format change would break existing vaults.
|
||||||
|
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||||
|
- The `groups.cache` plaintext "failures silently swallowed" doc-comment from current `helpers.rs:90-93` must be preserved on the new `after_manifest_change` wrapper. Don't change the policy.
|
||||||
|
- Do not merge your branch to main. The PM owns merges.
|
||||||
|
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||||
|
|
||||||
|
**Internal phase sequencing (within Stream B):**
|
||||||
|
- Phase 5 (`ParamsFile`) is independent — ship first to get it out of the way, OR last for diff-locality with the session-touching Phase 4. Either is fine; pick whichever reviews more cleanly.
|
||||||
|
- Phase 4 (`after_manifest_change`) before Phase 6 (`batched purge`). Phase 6's commit logic relies on the wrapper.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of four terminals. The PM coordinates you with Dev-A and Dev-C.
|
||||||
|
|
||||||
|
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||||
|
|
||||||
|
- When you dispatch a subagent
|
||||||
|
- When a subagent returns with a decision worth flagging (a found-but-unexpected coupling, a trade-off taken)
|
||||||
|
- When a sub-task completes (e.g. `after_manifest_change` wrapper landed; first manifest-mutation site converted; `ParamsFile` round-trip test green)
|
||||||
|
- When you change direction or hit something unexpected
|
||||||
|
- When you start a new phase
|
||||||
|
|
||||||
|
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "Phase 5 fixture test caught that `format_version` was previously emitted but never read; preserved the field but kept the read side tolerant"; "found one manifest-mutation site in `commands/import.rs` that did NOT call `refresh_groups_cache` historically (DEV-B notes flagged 7 sites; this is an 8th — surfacing as a question)." Print every STATUS UPDATE locally too.
|
||||||
|
|
||||||
|
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")`, then post and print using:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Time: <iso8601>
|
||||||
|
Branch: feature/cli-tail-stream-b-session-manifest
|
||||||
|
Task: <phase number / sub-step>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line>
|
||||||
|
Tests: <green | red (which failed) | N/A>
|
||||||
|
Notes: <WHAT and WHY — 3 sentences max>
|
||||||
|
```
|
||||||
|
|
||||||
|
**For PM input mid-task**:
|
||||||
|
|
||||||
|
```
|
||||||
|
## QUESTION TO PM — DEV-B
|
||||||
|
Time: <iso8601>
|
||||||
|
Context: <what task, what decision point>
|
||||||
|
Options: <A: ... / B: ... / C: ...>
|
||||||
|
Recommended: <your pick + one-sentence rationale>
|
||||||
|
Blocker: yes | no
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||||
|
|
||||||
|
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||||
|
|
||||||
|
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||||
|
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||||
|
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||||
|
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||||
|
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
## Authority within Phases 4-6
|
||||||
|
|
||||||
|
You don't need PM permission to:
|
||||||
|
|
||||||
|
- Execute sub-steps per Plan B's Phases 4, 5, 6
|
||||||
|
- Make implementation decisions consistent with Plan B
|
||||||
|
- Write tests, refactor your own code, fix bugs you introduce
|
||||||
|
- Push commits to your feature branch
|
||||||
|
|
||||||
|
You **do** escalate when:
|
||||||
|
|
||||||
|
- A scope question outside Plan B Phases 4-6
|
||||||
|
- A test you can't make green after honest debugging
|
||||||
|
- A discovered bug not in Plan B
|
||||||
|
- Anything destructive (per `CLAUDE.md`)
|
||||||
|
- Before opening the PR for review
|
||||||
|
- If you find an unexpected manifest-mutation site beyond the seven DEV-B notes flagged (likely surfaces in Phase 4)
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run the project's full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario.cli-tail-stream-b
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace
|
||||||
|
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
All three must be green / clean. Then push and open the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/cli-tail-stream-b-session-manifest
|
||||||
|
gh pr create --base main --head feature/cli-tail-stream-b-session-manifest --title "refactor(cli): session/manifest discipline (Plan B Phases 4, 5, 6)" --body "$(cat <<'EOF'
|
||||||
|
## Summary
|
||||||
|
- Phase 4 — `Vault::after_manifest_change` wrapper funnels seven manifest-mutation sites; `save_manifest` made `pub(crate)` so callers can't bypass the wrapper
|
||||||
|
- Phase 5 — Single canonical `ParamsFile` in `session.rs` replaces the two-definition split; on-disk JSON round-trips with existing vaults (fixture-string test)
|
||||||
|
- Phase 6 — Batched purge: a 50-item `trash empty` now fires 3 git invocations instead of 150
|
||||||
|
|
||||||
|
## Plan B Phases 4-6
|
||||||
|
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 4, 5, 6.
|
||||||
|
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
- [x] cargo test --workspace
|
||||||
|
- [x] cargo clippy --workspace
|
||||||
|
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||||
|
- [x] params.json round-trip test against existing on-disk format
|
||||||
|
- [x] `trash empty` with N items produces 1 commit (regression invariant)
|
||||||
|
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-b-session-manifest`), then start Phase 4 (or Phase 5 if you prefer to ship the independent piece first — call it out in the status update).
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Dev C Kickoff Prompt — CLI Tail (Cycle 2) Stream C
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Stream C of the CLI-tail cycle-2 release.
|
||||||
|
|
||||||
|
Stream C is **Plan B Phases 7 and 8** — the parser migration to `relicario-core` plus the WASM seam. Two phases, M effort:
|
||||||
|
|
||||||
|
- **Phase 7** — Migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` from `crates/relicario-cli/src/parse.rs` into `relicario-core` (`MonthYear::parse` on `time.rs`, new `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648_lenient`, new `mime::guess_for_extension`). Pair with DEV-A's P2 base32 dedup: extract the inline `base32_encode` from `crates/relicario-core/src/item.rs:255-275` and `decode_base32_totp` from `crates/relicario-core/src/import_lastpass.rs:202-220` into the new shared module. Steam's `STEAM_ALPHABET` at `item_types/totp.rs:13` stays untouched (with a neighbour comment). The CLI's `parse.rs` becomes a thin re-export shim — no callsite changes in cycle 2.
|
||||||
|
- **Phase 8** — `#[wasm_bindgen]` exports for the three migrated parsers (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) plus the matching declarations in `extension/src/wasm.d.ts`. snake_case JS naming consistent with every existing export. Plan C (extension restructure) does NOT consume these this round — the seam ships in cycle 2; consumption is a future plan.
|
||||||
|
|
||||||
|
Phase definitions are canonical in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8. Internal sequencing: Phase 7 before Phase 8.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-B (Plan B Phases 4, 5, 6). With the relay server running, you communicate via `post_message` / `read_messages` directly.
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git worktree add ../relicario.cli-tail-stream-c -b feature/cli-tail-stream-c-core-wasm-seam
|
||||||
|
cd ../relicario.cli-tail-stream-c
|
||||||
|
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-c
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-c`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-c` so subagents don't accidentally commit to main. Non-negotiable.
|
||||||
|
|
||||||
|
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-c"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cycle-1 lessons baked in (read once):**
|
||||||
|
|
||||||
|
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||||
|
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 DEV-A and DEV-B both hit this; documenting once here so cycle-2 DEV-C does not.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 7 + 8 only
|
||||||
|
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 7 and 8)
|
||||||
|
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
|
||||||
|
5. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — DEV-A's notes; the relevant section is the P2 "three base32 implementations" finding (the dedup that pairs with your Phase 7)
|
||||||
|
6. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the parser-migration P2 (line-level context for `parse_month_year`, `base32_decode_lenient`, `guess_mime`)
|
||||||
|
7. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read **only** the "Boundary notes for DEV-B" section (cross-boundary contracts — `wasm.d.ts` is hand-maintained; every change must mirror; BigInt typing care for `attachment_encrypt`-style paths, but your three new exports take only `&str` and return primitives so they avoid that class)
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review.
|
||||||
|
|
||||||
|
**Every subagent prompt MUST start with**:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||||
|
```
|
||||||
|
|
||||||
|
…before any other instruction.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Plan B Phases 7 and 8 — parser migration to `relicario-core` (paired with DEV-A P2 base32 dedup), then WASM exports + `extension/src/wasm.d.ts` mirror.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + builder compression
|
||||||
|
- Phases 4, 5, 6 (Dev-B owns) — session/manifest discipline
|
||||||
|
- Plan C (extension restructure) — consumption of your new WASM exports is explicitly deferred to a future plan; you ship the seam, you do NOT wire SW message handlers in the extension.
|
||||||
|
- Anything outside Plan B Phases 7-8. If you trip over an out-of-scope issue (e.g. a fourth base32 implementation surfaces; a parser the CLI uses that wasn't in Plan B's three), file a `## QUESTION TO PM` block and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- Steam's `STEAM_ALPHABET` at `crates/relicario-core/src/item_types/totp.rs:13` is intentionally non-RFC-4648; do NOT consolidate it into the new shared base32 module. Add a neighbour comment: `// not RFC 4648 — Steam Guard's de-ambiguated alphabet; see crate::base32 for the standard impl.`
|
||||||
|
- The CLI's `parse.rs` becomes a thin re-export shim — keep callsite imports unchanged in cycle 2 (no caller-side import churn).
|
||||||
|
- WASM JS naming stays snake_case for the three new exports — consistent with every existing `#[wasm_bindgen]` export. Do NOT introduce camelCase here; that decision is explicitly deferred per Plan B.
|
||||||
|
- `extension/src/wasm.d.ts` mirror lands in the same commit as the Rust `#[wasm_bindgen]` additions. Both sides updated together; no half-state.
|
||||||
|
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||||
|
- Do not merge your branch to main. The PM owns merges.
|
||||||
|
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||||
|
|
||||||
|
**Internal phase sequencing (within Stream C):**
|
||||||
|
- Phase 7 (parser migration to core + base32 dedup) before Phase 8 (WASM exports). Phase 8 imports from the new core paths; Phase 7 must compile clean first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of four terminals. The PM coordinates you with Dev-A and Dev-B.
|
||||||
|
|
||||||
|
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||||
|
|
||||||
|
- When you dispatch a subagent
|
||||||
|
- When a subagent returns with a decision worth flagging (an unexpected coupling, an alternative API shape considered, a found-but-flagged out-of-scope issue)
|
||||||
|
- When a sub-task completes (e.g. base32 module landed; `MonthYear::parse` integrated; first WASM export wired)
|
||||||
|
- When you change direction or hit something unexpected
|
||||||
|
- When you start a new phase
|
||||||
|
|
||||||
|
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "subagent surfaced a fourth base32 callsite in `crates/relicario-core/src/manifest.rs:??`; not in DEV-A P2's flagged list — escalating as a question"; "kept `MonthYear::parse` returning `Result<Self, RelicarioError>` rather than touching `MonthYear::new`'s `&'static str` per Plan B's recommendation; `new`-to-`RelicarioError` is DEV-A's separate P3"; "WASM exports compile clean; `wasm.d.ts` mirror passes `tsc --noEmit` in `extension/`." Print every STATUS UPDATE locally too.
|
||||||
|
|
||||||
|
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message` and print here. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-C
|
||||||
|
Time: <iso8601>
|
||||||
|
Branch: feature/cli-tail-stream-c-core-wasm-seam
|
||||||
|
Task: <phase number / sub-step>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line>
|
||||||
|
Tests: <green | red (which failed) | N/A>
|
||||||
|
Notes: <WHAT and WHY — 3 sentences max>
|
||||||
|
```
|
||||||
|
|
||||||
|
**For PM input mid-task**:
|
||||||
|
|
||||||
|
```
|
||||||
|
## QUESTION TO PM — DEV-C
|
||||||
|
Time: <iso8601>
|
||||||
|
Context: <what task, what decision point>
|
||||||
|
Options: <A: ... / B: ... / C: ...>
|
||||||
|
Recommended: <your pick + one-sentence rationale>
|
||||||
|
Blocker: yes | no
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||||
|
|
||||||
|
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||||
|
|
||||||
|
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||||
|
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||||
|
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||||
|
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||||
|
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
## Authority within Phases 7-8
|
||||||
|
|
||||||
|
You don't need PM permission to:
|
||||||
|
|
||||||
|
- Execute sub-steps per Plan B's Phases 7 and 8
|
||||||
|
- Make implementation decisions consistent with Plan B
|
||||||
|
- Write tests, refactor your own code, fix bugs you introduce
|
||||||
|
- Push commits to your feature branch
|
||||||
|
|
||||||
|
You **do** escalate when:
|
||||||
|
|
||||||
|
- A scope question outside Plan B Phases 7-8
|
||||||
|
- A test you can't make green after honest debugging
|
||||||
|
- A discovered bug not in Plan B
|
||||||
|
- A fourth base32 implementation or a parser surfaces beyond DEV-A P2 + Plan B's three
|
||||||
|
- Anything destructive (per `CLAUDE.md`)
|
||||||
|
- Before opening the PR for review
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run the project's full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace
|
||||||
|
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||||
|
cd extension && npm run test # verify wasm.d.ts mirror compiles against TS callers
|
||||||
|
```
|
||||||
|
|
||||||
|
All four must be green / clean. Then push and open the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||||
|
git push -u origin feature/cli-tail-stream-c-core-wasm-seam
|
||||||
|
gh pr create --base main --head feature/cli-tail-stream-c-core-wasm-seam --title "refactor(core,wasm): migrate parsers + base32 dedup + WASM exports (Plan B Phases 7, 8)" --body "$(cat <<'EOF'
|
||||||
|
## Summary
|
||||||
|
- Phase 7 — `parse_month_year`, `base32_decode_lenient`, `guess_mime` migrated from CLI to `relicario-core` (`MonthYear::parse`, new `pub(crate) mod base32`, new `mime::guess_for_extension`); base32 dedup folds `crates/relicario-core/src/item.rs:255-275` and `import_lastpass.rs:202-220` into the new shared module (Steam alphabet untouched per neighbour comment)
|
||||||
|
- Phase 8 — `#[wasm_bindgen]` exports for the three migrated parsers; `extension/src/wasm.d.ts` mirror updated in the same commit; snake_case JS naming consistent with existing exports
|
||||||
|
- The CLI's `parse.rs` is a thin re-export shim; existing CLI callsites unchanged
|
||||||
|
|
||||||
|
## Plan B Phases 7-8
|
||||||
|
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8.
|
||||||
|
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
- [x] cargo test --workspace
|
||||||
|
- [x] cargo clippy --workspace
|
||||||
|
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||||
|
- [x] cd extension && npm run test (verifies wasm.d.ts compiles)
|
||||||
|
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||||
|
- [x] Existing crates/relicario-core/tests/* pass without modification
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
- Extension consumption of the new WASM exports — Plan C territory; no SW message handlers wired in this PR
|
||||||
|
- camelCase JS naming for the three new exports — explicitly snake_case per Plan B; the camelCase decision is its own future plan
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-c-core-wasm-seam`), then start Phase 7 sub-step 1 (create `crates/relicario-core/src/base32.rs` with the unified `encode_rfc4648` / `decode_rfc4648_lenient` shape).
|
||||||
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal file
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# PM Kickoff Prompt — CLI Tail (Cycle 2)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the **project manager** for the CLI-tail cycle-2 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals.
|
||||||
|
|
||||||
|
This release has no version tag — it's the second cycle of the architecture-review structural-cleanup bundle. Cycle 1 shipped Plan A (security + docs polish) and Plan B Phases 1 + 2 (mechanical `main.rs` split + `git_run` helper). Cycle 2 partitions the remaining six Plan B phases (3 through 8) across three independent streams. Plan C (extension restructure) is *not* in cycle 2 — it stays pending until DEV-C bandwidth is available, on its own kickoff.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Working directory: `/home/alee/Sources/relicario`
|
||||||
|
- Branch: stay on `main`. Do not check out feature branches.
|
||||||
|
- Today: 2026-05-09. Project rules in `CLAUDE.md` apply (Spanish flourish in chat replies only, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking, default to subagent-driven execution, force-cd subagents into their worktree).
|
||||||
|
|
||||||
|
**Pre-launch state assumed:** cycle-1 Stream A merged, cycle-1 Stream B PR (Phase 1 + 2) merged, working tree clean on `main`, relay server alive on `localhost:7331`. Verify with `git log --oneline -5` and `ss -ltn 'sport = :7331'` before sending opening directives. If either is not in place, surface to the user before proceeding.
|
||||||
|
|
||||||
|
## Cycle 1 outcomes (read for context — your context starts cold)
|
||||||
|
|
||||||
|
The cycle-1 four-agent run (`docs/superpowers/coordination/2026-05-04-arch-followup-*-prompt.md`) produced:
|
||||||
|
|
||||||
|
- **Stream A (security + docs polish)** merged to `main`. Key commits: `1e858e1` impl Drop for SessionHandle, `03d0781` SW free() unswallow, `229e483` recovery_qr.rs documentation, `f8296fa` rustdoc warning fix on a private intra-doc link, `0c9387f` start.sh fourth-window. Plan A complete.
|
||||||
|
- **Stream B (CLI restructure Phases 1 + 2 only)** merged to `main` per a 2026-05-09 RESCOPE directive that halted Plan B at Phase 2 to enable cycle-2 parallelization. Key commits: `97c8f99` 15-site git_run sweep, `f3cdbed` git_run helper. `main.rs` shipped at 509 LOC (vs spec's ≤500); the 9-LOC overshoot is `#[arg(...)]` attribute density on 9 sub-enums and was accepted at merge — substance criterion (clap surface + dispatch + 2 shim families only) was met. DEV-B chose Plan B's option (b) for `git_run` (capture stderr + replay on failure) over option (a) (terminal-aware streaming).
|
||||||
|
- **Stream C (extension restructure)** did NOT launch in cycle 1 (cycle-1 DEV-C never acked). Plan C remains pending and is *not* part of cycle 2 — it is a multi-week effort scheduled separately on its own kickoff.
|
||||||
|
- **17 pre-existing extension test failures** on the kickoff baseline `bd3d53f` were documented in cycle-1 Stream A's PR. They sit in `extension/src/{service-worker,popup}/...` (devices/router/settings clusters) and pre-date the architecture review. Treat as the regression baseline: any cycle-2 red test outside this 17-failure cluster is a new regression and a stream's responsibility.
|
||||||
|
|
||||||
|
## Lessons learned (bake into your coordination)
|
||||||
|
|
||||||
|
Cycle 1 surfaced three operational gotchas worth pre-empting:
|
||||||
|
|
||||||
|
- **Prefer single-line relay message bodies.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals in body content. Compose `body` fields as a single line with sentences separated by periods; use ` -- ` for stronger breaks. The relay itself accepts multi-line bodies, but the consuming dev's monitor may not.
|
||||||
|
- **Python f-string footgun in inbox-monitor scripts.** If a dev reports a `SyntaxError: unexpected character after line continuation character`, their polling script likely uses `print(f"... {m.get(\"from\")} ...")` — Python f-strings cannot contain backslash-escaped quotes inside brace expressions. Fix is single quotes: `m.get('from')`.
|
||||||
|
- **Narration policy is non-negotiable.** Cycle 1 added it mid-run; cycle 2 has it baked into every kickoff. Devs MUST emit `Status: IN-PROGRESS` updates at meaningful in-flight moments (subagent dispatch, surprise findings, sub-task complete, phase start), not just at phase boundaries. You MUST narrate to the user in plain prose between tool calls — when a STATUS UPDATE lands, summarize it for the user before deciding; when you send a directive, state the rationale; when you dispatch a subagent, say so. Enforce both.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — **partition spec for this cycle. The canonical source for who owns what.**
|
||||||
|
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (phase definitions). Cycle 2 executes Phases 3 through 8.
|
||||||
|
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — original synthesis (read the P-tags Plan B addresses: P1.2, P1.3, P1.10, plus the four CLI P2s)
|
||||||
|
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (line-level context the synthesis abbreviates)
|
||||||
|
|
||||||
|
You do NOT need to read Plans A or C in detail — they're out of cycle-2 scope. Skim the partition coordinator's "Cross-stream dependencies" section so you know what conflicts to watch for.
|
||||||
|
|
||||||
|
## Stream overview (from coordinator)
|
||||||
|
|
||||||
|
| Stream | Branch | Owner | Plan B phases | Theme |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A | `feature/cli-tail-stream-a-prompt-helpers` | DEV-A | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
|
||||||
|
| B | `feature/cli-tail-stream-b-session-manifest` | DEV-B | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
|
||||||
|
| C | `feature/cli-tail-stream-c-core-wasm-seam` | DEV-C | Phases 7, 8 | parser migration to core + base32 dedup + WASM exports |
|
||||||
|
|
||||||
|
**No interface contracts between streams.** All three are independent once the cycle-1 PRs have merged. Conflict surface: `commands/add.rs` (A modifies builders; B modifies a manifest-mutation callsite). Whichever stream opens its PR second rebases.
|
||||||
|
|
||||||
|
## Your authority
|
||||||
|
|
||||||
|
- Approve or deny scope changes from devs
|
||||||
|
- Review and merge PRs from each stream's feature branch
|
||||||
|
- Edit `docs/`, `CLAUDE.md`, or other doc artifacts as needed; do not write feature code
|
||||||
|
|
||||||
|
## Your boundaries
|
||||||
|
|
||||||
|
- Don't write feature code yourself. Edits to docs / `CLAUDE.md` are fine.
|
||||||
|
- Don't deviate from Plan B's phase definitions without user approval.
|
||||||
|
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||||
|
- Don't tag — no tag planned for this cycle.
|
||||||
|
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`).
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"pm"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of four terminals. Use `post_message` / `read_messages` directly. Call `read_messages(for="pm")` before every action.
|
||||||
|
|
||||||
|
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is this terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly so the user sees the reasoning, not just the verdict. When you dispatch a subagent (e.g. for plan review or coherence pass), say so. One or two sentences per beat is plenty — the goal is for the user to read this terminal top-to-bottom and understand the release as a story.
|
||||||
|
|
||||||
|
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
|
||||||
|
|
||||||
|
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## DIRECTIVE TO DEV-<letter>
|
||||||
|
Time: <iso8601>
|
||||||
|
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||||
|
Notes: <one paragraph max>
|
||||||
|
Next: <one concrete instruction or "continue plan">
|
||||||
|
```
|
||||||
|
|
||||||
|
When asked "status?" by the user, give a current rollup:
|
||||||
|
|
||||||
|
```
|
||||||
|
## RELEASE STATUS — CLI Tail (Cycle 2)
|
||||||
|
Devs: <per-dev one-line state>
|
||||||
|
PM: <what you're working on>
|
||||||
|
Blockers: <list, or "none">
|
||||||
|
Next milestone: <e.g., "Stream A REVIEW-READY", "all three streams merged">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reviewing PRs
|
||||||
|
|
||||||
|
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||||
|
|
||||||
|
1. `gh pr view <url>` to read description and CI status
|
||||||
|
2. `gh pr diff <url>` to read changes
|
||||||
|
3. Check the diff against Plan B's "Done criteria" entries for that stream's phases
|
||||||
|
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash per project convention)
|
||||||
|
5. If red: post `Action: HOLD` with specific concerns
|
||||||
|
|
||||||
|
Use `superpowers:requesting-code-review` if you want a deeper independent review from a fresh subagent before approving.
|
||||||
|
|
||||||
|
## Pre-merge checklist (per stream)
|
||||||
|
|
||||||
|
Before each `MERGE-APPROVED`:
|
||||||
|
|
||||||
|
- [ ] Plan B's "Done criteria" for the stream's owned phases all checked
|
||||||
|
- [ ] `cargo test --workspace` green on the stream's worktree
|
||||||
|
- [ ] `cargo clippy --workspace` silent
|
||||||
|
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, Stream C especially)
|
||||||
|
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` pass without modification
|
||||||
|
- [ ] Narration discipline observed in the PR's STATUS UPDATE history
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||||
|
2. Verify pre-launch state: `git log --oneline -5 main`, `git status`, `ss -ltn 'sport = :7331'`. If any check fails, surface to the user before proceeding.
|
||||||
|
3. Emit a `## RELEASE STATUS` block confirming context absorbed.
|
||||||
|
4. Wait for setup-acknowledge STATUS UPDATEs from all three devs (their kickoff prompts have them post one after creating their worktree). Once all three are in, post opening `PROCEED` directives confirming each stream's plan path and phase scope.
|
||||||
|
5. Standing watch: drain inbox before each action; respond to QUESTIONs and STATUS UPDATEs as they arrive.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Dev-A Kickoff Prompt — Relicario extension-restructure (Phase 3)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Dev-A** for the Relicario extension-restructure release.
|
||||||
|
|
||||||
|
**Goal:** Own Phase 3 in its entirety — migrating the setup wizard's direct WASM orchestration into the service worker as two new SW handlers (`create_vault` and `attach_vault`), then converting the six `renderStepN`/`attachStepN` pairs into the `SetupStep` step-registry pattern and adding `clearWizardState`. This is the largest single phase: seven tasks, heavy orchestration logic, and builds on Phase 1's typed `StateHost` foundation (already shipped).
|
||||||
|
|
||||||
|
**Architecture:** Phase 3 is entirely in the extension. `setup.ts` shrinks from ~1220 LOC to ~500 LOC. No Rust crates, no `relicario-wasm` WASM surface, and no new runtime dependencies are added.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, vitest + happy-dom, webpack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup — run these FIRST
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario worktree add /home/alee/Sources/relicario.ext-restructure-a -b feature/extension-restructure-phase-a
|
||||||
|
```
|
||||||
|
|
||||||
|
Then confirm the worktree exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /home/alee/Sources/relicario.ext-restructure-a
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.ext-restructure-a`.**
|
||||||
|
|
||||||
|
Every subagent prompt MUST begin with:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.ext-restructure-a &&
|
||||||
|
```
|
||||||
|
|
||||||
|
Never rely on working-directory headers alone — subagents ignore them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Already-shipped context
|
||||||
|
|
||||||
|
- **Phase 1** (typed `StateHost` + `__resetHostForTests`): MERGED to main.
|
||||||
|
- **Phase 2** (SW router helpers extracted to `storage.ts` + `vault.ts`): MERGED to main.
|
||||||
|
- **Phase 5** (5 P2 fixes): MERGED to main.
|
||||||
|
- Baseline: **389/389 vitest tests pass** on main as of the start of this session.
|
||||||
|
- Do NOT re-do any Phase 1, 2, or 5 work. If you find those files already updated, that is expected — proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required reading
|
||||||
|
|
||||||
|
Read these before touching any code:
|
||||||
|
|
||||||
|
1. `/home/alee/Sources/relicario.ext-restructure-a/CLAUDE.md` — project rules (Spanish sprinkle in replies; auto-yes on recommended options; pause before destructive ops)
|
||||||
|
2. `/home/alee/Sources/relicario.ext-restructure-a/docs/superpowers/plans/2026-05-30-extension-restructure.md` — the full plan; Phase 3 is Tasks 3.1-3.7
|
||||||
|
3. `/home/alee/Sources/relicario.ext-restructure-a/extension/ARCHITECTURE.md` — bundle structure, SW↔popup contract, component architecture
|
||||||
|
4. `/home/alee/Sources/relicario.ext-restructure-a/extension/src/setup/setup.ts` — read fully before Task 3.2; the SW handlers must mirror this orchestration exactly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **`superpowers:subagent-driven-development`**. Spawn a fresh subagent per task. Two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.ext-restructure-a &&`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope — own exactly this
|
||||||
|
|
||||||
|
**Phase 3 (Tasks 3.1-3.7):**
|
||||||
|
|
||||||
|
| Task | Summary |
|
||||||
|
|---|---|
|
||||||
|
| 3.1 | Add `create_vault` / `attach_vault` / `get_vault_status` to `messages.ts` |
|
||||||
|
| 3.2 | Implement `create_vault` SW handler in `service-worker/vault.ts` + tests |
|
||||||
|
| 3.3 | Implement `attach_vault` SW handler in `service-worker/vault.ts` + tests |
|
||||||
|
| 3.4 | Delete WASM dynamic-import + `loadWasm` + `verifiedHandle` from `setup.ts` |
|
||||||
|
| 3.5 | Replace WASM calls with `sendMessage(create_vault / attach_vault)` + convert `renderStepN`/`attachStepN` pairs to `SetupStep` step-registry |
|
||||||
|
| 3.6 | Add `clearWizardState()` + `beforeunload` binding + call on `goto('mode')` |
|
||||||
|
| 3.7 | Update setup tests to assert on step-registry shape; add `clearWizardState` test |
|
||||||
|
|
||||||
|
**Out of scope — do not touch:**
|
||||||
|
- Phase 4 (Tasks 4.1-4.7): vault.ts split into 5 focused modules
|
||||||
|
- Phase 6 (Tasks 6.1-6.3): `get_vault_status` parity feature (vault-status.ts + sidebar indicator)
|
||||||
|
|
||||||
|
If you find bugs outside Phase 3 scope, file a `## QUESTION TO PM` block and relay it. Do not fix them yourself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- **Maintain or grow the 389-test baseline.** No vitest regressions. If a task temporarily breaks tests (Tasks 3.4 and 3.5 do — by design, before 3.7 fixes them), track it explicitly and fix before the final commit.
|
||||||
|
- **TDD for new logic.** Write failing tests before implementing `create_vault` and `attach_vault` handlers (Tasks 3.2, 3.3).
|
||||||
|
- **Commit after each logical step.** Per the plan's commit messages: Task 3.1 = one commit; Task 3.2 = one commit; Task 3.3 = one commit; Tasks 3.4-3.7 = one cohesive commit (the plan bundles them because they only compile together).
|
||||||
|
- **Do not merge to main.** The PM owns merges.
|
||||||
|
- **Do not re-use `git amend` on previous commits.** Always create new commits.
|
||||||
|
- **Do not skip hooks (`--no-verify`).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
Relay runs at `localhost:7331`. Your identity is `from="dev-a"`.
|
||||||
|
|
||||||
|
Read your inbox with this Python shim (run from any directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-a"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Post to PM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay && python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Recipients: `pm`, `dev-a`, `dev-b`. Read your inbox before each task. Post status/questions after each task and whenever a decision is made, a surprise is found, or direction changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STATUS UPDATE format
|
||||||
|
|
||||||
|
Print locally AND relay to `pm` after every task and at each meaningful moment:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-A
|
||||||
|
Time: <iso8601>
|
||||||
|
Task: <N of 7>
|
||||||
|
Status: COMPLETE | IN-PROGRESS | BLOCKED
|
||||||
|
Notes: <what you did + why, 3 sentences max>
|
||||||
|
Next: <next task or "waiting for PM">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Narration discipline
|
||||||
|
|
||||||
|
Emit IN-PROGRESS updates (locally and relayed) at:
|
||||||
|
- Each subagent dispatched
|
||||||
|
- Each significant decision made (e.g., "chose to export `__test__` for test-only access rather than polluting the public API")
|
||||||
|
- Each surprise found (unexpected type error, missing stub, existing test that conflicts)
|
||||||
|
- Any direction change mid-task
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task detail reference
|
||||||
|
|
||||||
|
The full task steps (including exact code snippets, grep commands, and commit messages) live in:
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/alee/Sources/relicario.ext-restructure-a/docs/superpowers/plans/2026-05-30-extension-restructure.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Sections: `## Phase 3 — Setup wizard SW migration + step registry (P1.4)` through `### Task 3.7`.
|
||||||
|
|
||||||
|
Key orchestration note for Tasks 3.2 and 3.3: the SW handlers must mirror the exact sequence currently in `setup.ts`. Read `setup.ts` fully before implementing — the plan cannot enumerate every line because `setup.ts` is the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
After all seven tasks are committed, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario.ext-restructure-a && pnpm --filter extension test && pnpm --filter extension build
|
||||||
|
```
|
||||||
|
|
||||||
|
All 389+ tests must pass. Build must be clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull request
|
||||||
|
|
||||||
|
When tests and build are clean:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base main --title "feat(extension): restructure Phase 3 (Tasks 3.1-3.7): add create_vault/attach_vault/get_vault_status to messages.ts; implement create_vault SW handler + tests; implement attach_vault SW handler + tests; delete WASM imports/loadWasm/verifiedHandle from setup.ts; replace WASM calls with sendMessage + step-registry conversion; add clearWizardState + beforeunload binding; update setup tests + add clearWizardState test — Dev-A"
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the PR URL in a STATUS UPDATE to PM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
1. Run the worktree setup command above.
|
||||||
|
2. Confirm the worktree path exists.
|
||||||
|
3. Emit a STATUS UPDATE: Task 0 of 7 / Status: COMPLETE / Notes: Worktree created at /home/alee/Sources/relicario.ext-restructure-a on branch feature/extension-restructure-phase-a. / Next: Task 3.1 — add message types.
|
||||||
|
4. Relay that status to pm.
|
||||||
|
5. Read your inbox (`read_messages for="dev-a"`).
|
||||||
|
6. Start Task 3.1.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Dev-B Kickoff Prompt — extension-restructure (Phase 4 + Phase 6)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Dev-B** for the Relicario **extension-restructure** release.
|
||||||
|
|
||||||
|
**Goal:** Own Phase 4 and Phase 6 in sequence. Phase 4 splits the 1027-LOC `vault.ts` monolith into five focused modules (`vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`) and lifts the `vault_locked` RPC intercept into `shared/state.ts`, building on the Phase 1 `StateHost` foundation that is already shipped. Phase 6 closes the CLI/extension parity gap by implementing the `get_vault_status` SW handler and wiring the sidebar status indicator — it depends on the `vault-sidebar.ts` module that Phase 4 produces.
|
||||||
|
|
||||||
|
**Architecture:** TypeScript extension only. No Rust crates touched. All new modules live in `extension/src/vault/` (Phase 4) and `extension/src/service-worker/` (Phase 6). The `StateHost` foundation (`shared/state.ts`, typed `PopupState`, `__resetHostForTests`) was shipped in Phase 1 and is already on `main`. Do not redo it.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, vitest + happy-dom, webpack, Rust core via WASM (no new WASM entry points needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0 — Worktree setup (do this FIRST, before anything else)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario worktree add /home/alee/Sources/relicario.ext-restructure-b -b feature/extension-restructure-phase-b
|
||||||
|
```
|
||||||
|
|
||||||
|
Then all subsequent work happens in `/home/alee/Sources/relicario.ext-restructure-b`.
|
||||||
|
|
||||||
|
**ALL subagent prompts MUST begin with:**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.ext-restructure-b &&
|
||||||
|
```
|
||||||
|
|
||||||
|
Never rely on working-directory headers alone — subagents may commit to `main` if they do not force-cd into the worktree at prompt start.
|
||||||
|
|
||||||
|
After setup, emit:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Task: setup
|
||||||
|
Status: COMPLETE
|
||||||
|
Notes: Worktree created at /home/alee/Sources/relicario.ext-restructure-b on branch feature/extension-restructure-phase-b. Baseline test count confirmed.
|
||||||
|
Next: Phase 4 Task 4.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Post this update to the relay (see Relay section below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Already-shipped context
|
||||||
|
|
||||||
|
Phases 1, 2, and 5 have been merged to `main`. The following are done — do not redo:
|
||||||
|
|
||||||
|
- `shared/popup-state.ts` — `View` + `PopupState` types extracted
|
||||||
|
- `shared/state.ts` — typed `StateHost` with `registerHost`, `__resetHostForTests`, `sendMessage` wrapper
|
||||||
|
- `shared/__tests__/state.test.ts` — 7 StateHost tests
|
||||||
|
- `service-worker/storage.ts` — `loadDeviceSettings`, `saveDeviceSettings`, `loadBlacklist`, `saveBlacklist`
|
||||||
|
- Phase 5 P2 fixes (inactivity-timer invert, `Promise.allSettled` in devices/trash, MutationObserver debounce, `teardownSettingsCommon`, WASM stub rounding-out)
|
||||||
|
|
||||||
|
**Baseline:** 389/389 vitest tests pass on `main`. You must maintain or grow this count. Never let tests regress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required reading
|
||||||
|
|
||||||
|
Before writing any code, read:
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules (always applies)
|
||||||
|
2. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — authoritative plan; Phase 4 and Phase 6 task details are defined there
|
||||||
|
3. `extension/ARCHITECTURE.md` — bundle structure, SW message protocol, component architecture
|
||||||
|
4. `extension/src/vault/vault.ts` — the 1027-LOC monolith you will split (read it in full before Task 4.1)
|
||||||
|
5. `extension/src/shared/state.ts` — shipped StateHost contract (Phase 4 lifts `vault_locked` into `sendMessage` here)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use the **`superpowers:subagent-driven-development`** skill. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.ext-restructure-b &&`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Phase 4 — Split `vault.ts` monolith (Tasks 4.1–4.7)
|
||||||
|
|
||||||
|
You own all seven tasks:
|
||||||
|
|
||||||
|
- **Task 4.1** — Extract `vault-shell.ts`: DOM scaffolding, color-scheme apply, `onMessage` wiring
|
||||||
|
- **Task 4.2** — Extract `vault-sidebar.ts`: sidebar categories, debounced search, nav buttons, status slot wiring
|
||||||
|
- **Task 4.3** — Extract `vault-list.ts`: list pane rendering and row rendering
|
||||||
|
- **Task 4.4** — Extract `vault-drawer.ts` + `ensureDrawerClosedForRoute` + `drawer-state.test.ts`
|
||||||
|
- **Task 4.5** — Extract `vault-form-wrapper.ts`: `renderFormWrapped`, sticky bar, header
|
||||||
|
- **Task 4.6** — Trim `vault.ts` to ~200 LOC of routing + state (delete everything extracted above)
|
||||||
|
- **Task 4.7** — Lift `vault_locked` RPC intercept into `shared/state.ts` `sendMessage` + write `state-vault-locked.test.ts`
|
||||||
|
|
||||||
|
### Phase 6 — CLI/extension parity: `get_vault_status` (Tasks 6.1–6.3)
|
||||||
|
|
||||||
|
Phase 6 depends on `vault-sidebar.ts` from Phase 4. Do not start Phase 6 until Phase 4 is complete and all tests pass.
|
||||||
|
|
||||||
|
- **Task 6.1** — Implement `get_vault_status` SW handler in `extension/src/service-worker/vault.ts` + write `vault-status.test.ts`
|
||||||
|
- **Task 6.2** — Create `vault-status.ts` renderer (sidebar-footer status indicator) + write `status-indicator.test.ts`
|
||||||
|
- **Task 6.3** — Wire the status indicator into `vault-sidebar.ts` sidebar footer
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
Phase 3 (Tasks 3.1–3.7) is owned by another developer. Do NOT touch `setup.ts`, `setup/__tests__/setup.test.ts`, or the SW `create_vault` / `attach_vault` handlers. If you need to coordinate on a shared file, post a question to the relay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- **Maintain or grow the 389-test baseline.** No vitest regressions — ever.
|
||||||
|
- **TDD for all new logic.** Write the failing test first, then the implementation.
|
||||||
|
- **Commit after each task** (not each step — one logical commit per task, bundling its files).
|
||||||
|
- **No `as any` casts.** The typed `StateHost` contract is in place; use it.
|
||||||
|
- **Do not push or open a PR until both phases are complete and the final test run passes.**
|
||||||
|
- **Do not merge to `main`.** The PM owns merges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relay
|
||||||
|
|
||||||
|
A message-bus server is running at `localhost:7331`. Your identity is `from="dev-b"`.
|
||||||
|
|
||||||
|
**Python shim (use this to call the relay):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-b"}'
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay && python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Recipients: `pm`, `dev-a`, `dev-b`.
|
||||||
|
|
||||||
|
**Before each task:** call `read_messages` with `{"for":"dev-b"}` to drain your inbox.
|
||||||
|
|
||||||
|
**After each status update:** call `post_message` to relay your STATUS UPDATE block to `pm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STATUS UPDATE format
|
||||||
|
|
||||||
|
Use this format for every update — print it locally AND relay it to `pm`:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Task: <task id, e.g. 4.1>
|
||||||
|
Status: COMPLETE | IN-PROGRESS | BLOCKED
|
||||||
|
Notes: <what was done, why the approach was taken, any surprise found — 3 sentences max>
|
||||||
|
Next: <next task id or "waiting for PM">
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit IN-PROGRESS updates at meaningful moments: when a subagent is dispatched, a key architectural decision is made, a surprise is found, or a direction change occurs. Do not wait for phase boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 task details
|
||||||
|
|
||||||
|
Refer to `docs/superpowers/plans/2026-05-30-extension-restructure.md` for the full step-by-step breakdown of each task. The plan is authoritative. Below is a summary of what each task produces to orient you before you read the plan:
|
||||||
|
|
||||||
|
**Task 4.1 — `vault-shell.ts`**
|
||||||
|
Extracts: the `initVaultShell(container)` bootstrapper, `applyColorScheme()`, `document.addEventListener('message', ...)` wiring. `vault.ts` imports `initVaultShell` and calls it at startup.
|
||||||
|
|
||||||
|
**Task 4.2 — `vault-sidebar.ts`**
|
||||||
|
Extracts: `renderSidebar(container, state)`, debounced search input handler, category nav button click wiring, and a `<div class="vault-sidebar__status">` slot at the footer (empty until Phase 6 Task 6.3). Exports `renderSidebar` and `updateSidebarStatus(text: string)`.
|
||||||
|
|
||||||
|
**Task 4.3 — `vault-list.ts`**
|
||||||
|
Extracts: `renderList(container, entries, state)` and `renderRow(entry, state)`. The list pane is a pure render function — no side effects beyond DOM mutation.
|
||||||
|
|
||||||
|
**Task 4.4 — `vault-drawer.ts` + drawer tests**
|
||||||
|
Extracts: `openDrawer(view)`, `closeDrawer()`, `renderDrawerContent(view, state)`, and `ensureDrawerClosedForRoute(route)` (closes the drawer automatically when navigating to list/unlock). Creates `extension/src/vault/__tests__/drawer-state.test.ts` covering the auto-close behavior.
|
||||||
|
|
||||||
|
**Task 4.5 — `vault-form-wrapper.ts`**
|
||||||
|
Extracts: `renderFormWrapped(container, title, renderBody)` — the sticky-header + save-bar scaffold used by add/edit/detail views.
|
||||||
|
|
||||||
|
**Task 4.6 — Trim `vault.ts` to ~200 LOC**
|
||||||
|
After extracting all the above, `vault.ts` should contain only: route dispatch (`handleRoute`), top-level state management (`initVault`, `setState`), and import wiring. Delete the extracted code. Run the full test suite to confirm nothing broke.
|
||||||
|
|
||||||
|
**Task 4.7 — Lift `vault_locked` intercept into `shared/state.ts`**
|
||||||
|
The pre-Phase-4 `vault.ts` has a `vault_locked` channel intercept inside its local `sendMessage` wrapper. Lift this into the `sendMessage` export in `shared/state.ts` (Phase 1 left a placeholder comment there). Write `extension/src/shared/__tests__/state-vault-locked.test.ts` that:
|
||||||
|
- registers a mock host
|
||||||
|
- dispatches a `sendMessage` that returns `{ ok: false, error: 'vault_locked' }`
|
||||||
|
- asserts that `navigate('unlock')` was called on the host
|
||||||
|
- asserts the original rejection is re-thrown (or rethrown as appropriate per the existing intercept logic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 task details
|
||||||
|
|
||||||
|
Do not start Phase 6 until Phase 4 is fully committed and all 389+ tests pass.
|
||||||
|
|
||||||
|
**Task 6.1 — `get_vault_status` SW handler**
|
||||||
|
Add a `get_vault_status` case to `extension/src/service-worker/vault.ts`. The handler returns:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
unlocked: boolean, // whether a session is active
|
||||||
|
vault_dir: string | null, // from cached state.vaultDir
|
||||||
|
git_host: string | null, // from cached state.gitHost
|
||||||
|
item_count: number, // manifest entry count or 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add `get_vault_status` to `extension/src/shared/messages.ts` as a new `Request` variant.
|
||||||
|
Write `extension/src/service-worker/__tests__/vault-status.test.ts` covering: unlocked path, locked path, and missing-vault path.
|
||||||
|
|
||||||
|
**Task 6.2 — `vault-status.ts` renderer**
|
||||||
|
Create `extension/src/vault/vault-status.ts` with:
|
||||||
|
```typescript
|
||||||
|
export function renderVaultStatus(container: HTMLElement, status: VaultStatusData): void;
|
||||||
|
```
|
||||||
|
The renderer fills `container` with a one-line status indicator: a colored dot + short text (`Unlocked · 42 items` or `Locked` or `No vault`). Write `extension/src/vault/__tests__/status-indicator.test.ts` covering all three states with happy-dom.
|
||||||
|
|
||||||
|
**Task 6.3 — Wire indicator into `vault-sidebar.ts`**
|
||||||
|
At sidebar boot, call `sendMessage({ type: 'get_vault_status' })` and pass the result to `renderVaultStatus(statusSlot, data)`. Re-fetch on every `setState` call so the count stays current. The status slot element (`<div class="vault-sidebar__status">`) was created in Task 4.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
Before opening a PR, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario.ext-restructure-b && pnpm --filter extension test && pnpm --filter extension build
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests must pass. Build must be clean. Post your final STATUS UPDATE to `pm` with Status: COMPLETE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opening the PR
|
||||||
|
|
||||||
|
Once both phases are complete and the final run passes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base main --title "feat(extension): restructure Phase 4 (Tasks 4.1-4.7): extract vault-shell.ts; extract vault-sidebar.ts with debounced search; extract vault-list.ts; extract vault-drawer.ts + ensureDrawerClosedForRoute + drawer-state tests; extract vault-form-wrapper.ts; trim vault.ts to ~200 LOC routing+state; lift vault_locked intercept into shared/state.ts + state-vault-locked tests+Phase 6 (Tasks 6.1-6.3): implement get_vault_status SW handler + vault-status.test.ts; create vault-status.ts renderer + status-indicator tests; wire indicator into vault-sidebar.ts sidebar footer — Dev-B"
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the PR URL in your final STATUS UPDATE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
1. Run the worktree setup command above.
|
||||||
|
2. Confirm the baseline: `cd /home/alee/Sources/relicario.ext-restructure-b && pnpm --filter extension test 2>&1 | tail -5`
|
||||||
|
3. Emit STATUS UPDATE "setup complete" locally and relay it to `pm`.
|
||||||
|
4. Begin Phase 4 Task 4.1 by reading `extension/src/vault/vault.ts` in full, then dispatching a subagent.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Auto-generated by release workflow — extension-restructure
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO="/home/alee/Sources/relicario"
|
||||||
|
RELAY_DIR="$REPO/tools/relay"
|
||||||
|
COORD="$REPO/docs/superpowers/coordination"
|
||||||
|
RELEASE="extension-restructure"
|
||||||
|
SESSION="$RELEASE"
|
||||||
|
|
||||||
|
# ── 1. Relay ─────────────────────────────────────────────────────────────
|
||||||
|
if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then
|
||||||
|
echo "[relay] already running on :7331"
|
||||||
|
else
|
||||||
|
echo "[relay] starting..."
|
||||||
|
cd "$RELAY_DIR"
|
||||||
|
nohup npx tsx server.ts > /tmp/relay-extension-restructure.log 2>&1 &
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
sleep 1
|
||||||
|
if curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1; then
|
||||||
|
echo "[relay] ready on :7331"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 10 ]; then
|
||||||
|
echo "[relay] ERROR: failed to start — check /tmp/relay-extension-restructure.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. tmux session ──────────────────────────────────────────────────────
|
||||||
|
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||||
|
echo "[tmux] session '$SESSION' already exists — attaching"
|
||||||
|
exec tmux attach-session -t "$SESSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[tmux] creating session '$SESSION'..."
|
||||||
|
tmux new-session -d -s "$SESSION" -n "PM"
|
||||||
|
tmux send-keys -t "$SESSION:PM" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-A"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-A" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-B"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-B" "claude" Enter
|
||||||
|
|
||||||
|
tmux select-window -t "$SESSION:PM"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ extension-restructure — prompt cheatsheet ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════╣"
|
||||||
|
echo "║ PM window → paste $COORD/$RELEASE-pm-prompt.md ║"
|
||||||
|
echo "║ Dev-A window → paste $COORD/$RELEASE-dev-a-prompt.md ║"
|
||||||
|
echo "║ Dev-B window → paste $COORD/$RELEASE-dev-b-prompt.md ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "[tmux] attaching — use Ctrl-b n / Ctrl-b p to switch windows"
|
||||||
|
exec tmux attach-session -t "$SESSION"
|
||||||
184
docs/superpowers/coordination/extension-restructure-pm-prompt.md
Normal file
184
docs/superpowers/coordination/extension-restructure-pm-prompt.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# PM Kickoff Prompt — Relicario extension-restructure (Phases 3, 4, 6)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the **PM for the Relicario extension-restructure release (Phases 3, 4, 6)**. Two senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all three terminals; a relay server routes messages between you so the user does not need to copy-paste directives.
|
||||||
|
|
||||||
|
## Working directory
|
||||||
|
|
||||||
|
`/home/alee/Sources/relicario`
|
||||||
|
|
||||||
|
Stay on `main` in your own session. Do not check out feature branches. All file reads are against `main`. All doc/CHANGELOG edits happen here too.
|
||||||
|
|
||||||
|
## Required reading (read in this order before acting)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules. Pay attention to: Spanish flourish in chat replies only, product name capitalization ("Relicario"), "default to yes" autonomy, never run destructive git ops without asking the user.
|
||||||
|
2. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — the canonical implementation plan for this release. Phases 3, 4, and 6 are the live work. Phases 1, 2, and 5 are already merged (do not re-do them).
|
||||||
|
3. `extension/ARCHITECTURE.md` — bundle structure, SW ↔ popup message contract, component/pane architecture. Required to review PRs intelligently.
|
||||||
|
|
||||||
|
## Already-shipped context
|
||||||
|
|
||||||
|
Phases 1, 2, and 5 merged into `main` as of commit `8249f9e` (docs update) and `c3f8e35` (Phase 1 merge). The typed `StateHost` foundation (Phase 1) is in `extension/src/shared/state.ts` now. Phase 2 consolidated `storage.ts` and `itemToManifestEntry`. Phase 5 shipped the five P2 fixes (inactivity-timer inversion, `state.gitHost` clear, `teardownSettingsCommon`, `Promise.allSettled`, detector debounce).
|
||||||
|
|
||||||
|
**Current test baseline: 389/389 vitest passing.** This is the floor. Neither dev may land a PR that drops below this.
|
||||||
|
|
||||||
|
Do NOT re-implement any Phase 1, 2, or 5 work. If a dev proposes a change that touches already-shipped territory without a clear regression-fix justification, push back.
|
||||||
|
|
||||||
|
## Your authority
|
||||||
|
|
||||||
|
- Approve or deny scope changes from devs.
|
||||||
|
- Review PRs: run `gh pr view <n>` and `gh pr diff <n>` before approving.
|
||||||
|
- Write the CHANGELOG entry summarizing what shipped (the extension-restructure section).
|
||||||
|
- Request a tag once all done-criteria pass — **tag requires explicit user approval before you run `git tag`**.
|
||||||
|
- Edit `STATUS.md` and `ROADMAP.md` once all streams land.
|
||||||
|
- Run the final Task 7.1 verification sweep yourself (see Pre-tag checklist below).
|
||||||
|
|
||||||
|
## Your boundaries
|
||||||
|
|
||||||
|
- Write NO feature code. Editing `CHANGELOG.md`, `STATUS.md`, `ROADMAP.md`, and coordination docs is fine.
|
||||||
|
- Run NO destructive git operations (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`) without explicit user confirmation.
|
||||||
|
- Do not approve a PR until the dev signals `REVIEW-READY` in the relay.
|
||||||
|
- Do not tag without user approval.
|
||||||
|
- If you are uncertain about a PR's correctness, invoke the `superpowers:requesting-code-review` skill before approving.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus server is running at `localhost:7331`. Three native MCP tools are available in your session:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message. Your `from` is always `"pm"`.
|
||||||
|
- `read_messages(for)` — drain your inbox. Call with `for="pm"`.
|
||||||
|
- `list_pending(for)` — check inbox count without consuming.
|
||||||
|
|
||||||
|
Recipients: `pm`, `dev-a`, `dev-b`.
|
||||||
|
|
||||||
|
**Python shim fallback** (use if MCP tools are not registered — this happens when the relay was not running when your session opened):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"pm"}'
|
||||||
|
python3 call.py list_pending '{"for":"pm"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The shim connects over HTTP and has identical semantics to the MCP tools. Narrate what you are doing between tool calls so the user can follow your reasoning.
|
||||||
|
|
||||||
|
## Dev roster
|
||||||
|
|
||||||
|
| Role | Branch | Worktree path | Scope |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Dev-A | `feature/extension-restructure-phase-3` | `/home/alee/Sources/relicario/.worktrees/ext-restructure-phase-3` | Phase 3 entirely: migrate setup wizard's direct WASM orchestration into two new SW handlers (`create_vault`, `attach_vault`); convert the six `renderStepN`/`attachStepN` pairs into the `SetupStep` step-registry pattern; add `clearWizardState`. Tasks 3.1–3.7. Depends on Phase 1's typed `StateHost` (already shipped). |
|
||||||
|
| Dev-B | `feature/extension-restructure-phase-4-6` | `/home/alee/Sources/relicario/.worktrees/ext-restructure-phase-4-6` | Phase 4 then Phase 6 in sequence: Phase 4 splits the 1027-LOC `vault.ts` monolith into five focused modules (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`) and lifts the `vault_locked` RPC intercept into `shared/state.ts`. Tasks 4.1–4.7. Then Phase 6 adds the `get_vault_status` SW handler and wires the sidebar status indicator. Tasks 6.1–6.3. Phase 6 depends on the `vault-sidebar.ts` module that Phase 4 produces — Dev-B must fully merge Phase 4 before starting Phase 6. |
|
||||||
|
|
||||||
|
Both branches fork from the current `main` tip (commit `9fc07c3`).
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
### DIRECTIVE block format
|
||||||
|
|
||||||
|
When you send work instructions to a dev, structure the relay body like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
DIRECTIVE [phase/task]
|
||||||
|
---
|
||||||
|
<concise instruction — what to do, what files to touch, what to verify>
|
||||||
|
DONE SIGNAL: Reply with REVIEW-READY + PR number when complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
### RELEASE STATUS rollup format
|
||||||
|
|
||||||
|
When reporting status to the user (or to yourself at phase boundaries), use:
|
||||||
|
|
||||||
|
```
|
||||||
|
RELEASE STATUS — extension-restructure [date]
|
||||||
|
Phase 3 (Dev-A): [NOT STARTED | IN PROGRESS task N | REVIEW-READY | MERGED]
|
||||||
|
Phase 4 (Dev-B): [NOT STARTED | IN PROGRESS task N | REVIEW-READY | MERGED]
|
||||||
|
Phase 6 (Dev-B): [NOT STARTED — waiting on Phase 4 | IN PROGRESS task N | REVIEW-READY | MERGED]
|
||||||
|
Test baseline: [389 | current count] vitest passing
|
||||||
|
Blockers: [none | describe]
|
||||||
|
Next PM action: [describe]
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a RELEASE STATUS rollup:
|
||||||
|
- After absorbing required reading (your first action).
|
||||||
|
- Whenever a dev signals `REVIEW-READY`.
|
||||||
|
- After each PR merge.
|
||||||
|
- When a blocker surfaces.
|
||||||
|
|
||||||
|
## Merge-order safety rules (enforce strictly)
|
||||||
|
|
||||||
|
1. **Dev-B must fully merge Phase 4 before starting Phase 6.** `vault-sidebar.ts` is the wiring target for Phase 6's `get_vault_status` status indicator. If Dev-B opens a Phase 6 PR while Phase 4 is still open, reject it.
|
||||||
|
2. **Both devs depend on Phase 1's typed `StateHost` foundation (already on `main` at `c3f8e35`).** If either dev's branch diverges from current `main` before starting, ask them to rebase.
|
||||||
|
3. **Phase 3 and Phase 4 are independent of each other** — they can proceed in parallel. Dev-A and Dev-B may work simultaneously.
|
||||||
|
4. **Do not let either dev touch** `extension/src/wasm.d.ts` unless they have a concrete compilation error that demands it. The plan explicitly states this file is untouched for this release.
|
||||||
|
|
||||||
|
## PR review process
|
||||||
|
|
||||||
|
1. Dev signals `REVIEW-READY` with a PR number in the relay.
|
||||||
|
2. You run `gh pr view <n>` to read the description.
|
||||||
|
3. You run `gh pr diff <n>` to read the diff.
|
||||||
|
4. Check that the PR only touches files in the plan's scope for that phase.
|
||||||
|
5. Check the vitest count in the PR CI (or ask the dev to paste `npx vitest run` output).
|
||||||
|
6. If uncertain about correctness, invoke the `superpowers:requesting-code-review` skill before approving.
|
||||||
|
7. Approve with `gh pr review <n> --approve` and then merge with `gh pr merge <n> --merge`.
|
||||||
|
8. Post `DIRECTIVE` to dev confirming merge and what to do next.
|
||||||
|
|
||||||
|
## Pre-tag checklist (Task 7.1 — you run this yourself)
|
||||||
|
|
||||||
|
Run all of the following from `/home/alee/Sources/relicario/extension` after both Phase 3 and Phase 4+6 PRs are merged:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. TypeScript clean build
|
||||||
|
npx tsc --noEmit 2>&1 | tail -5
|
||||||
|
# Expected: no output
|
||||||
|
|
||||||
|
# 2. Full vitest suite
|
||||||
|
npx vitest run
|
||||||
|
# Expected: all 389+ tests pass (count must equal or exceed baseline)
|
||||||
|
|
||||||
|
# 3. Production webpack build
|
||||||
|
npm run build:all 2>&1 | tail -5
|
||||||
|
# Expected: both Chrome + Firefox targets compile with no errors
|
||||||
|
# (only the pre-existing 4 MB WASM size warning is acceptable)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the done-criteria checklist from the plan's Task 7.1 (lines 2549–2597 of `docs/superpowers/plans/2026-05-30-extension-restructure.md`). Key grep checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# No as-any in shared/state.ts public surface
|
||||||
|
grep -c ": any\|<any>" extension/src/shared/state.ts
|
||||||
|
|
||||||
|
# Router files have no duplicated storage helpers
|
||||||
|
grep -c "function loadDeviceSettings\|function loadBlacklist\|function saveBlacklist" extension/src/service-worker/router/*.ts
|
||||||
|
|
||||||
|
# setup.ts does not import relicario-wasm directly
|
||||||
|
grep -c "relicario-wasm" extension/src/setup/setup.ts
|
||||||
|
|
||||||
|
# SW handles all three new messages
|
||||||
|
grep -c "case 'create_vault'\|case 'attach_vault'\|case 'get_vault_status'" extension/src/service-worker/router/popup-only.ts
|
||||||
|
|
||||||
|
# vault.ts does not contain the vault_locked intercept
|
||||||
|
grep -c "vault_locked" extension/src/vault/vault.ts
|
||||||
|
|
||||||
|
# Sidebar search is debounced
|
||||||
|
grep "SEARCH_DEBOUNCE_MS" extension/src/vault/vault-sidebar.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
All of the above must pass. If any check fails, send the dev a DIRECTIVE to fix it before tagging.
|
||||||
|
|
||||||
|
Once all checks pass:
|
||||||
|
1. Write the CHANGELOG entry (under a new `## [Unreleased]` or the appropriate version header).
|
||||||
|
2. Update `STATUS.md`: move extension-restructure from in-flight to shipped.
|
||||||
|
3. Update `ROADMAP.md`: advance the pointer to whatever comes next.
|
||||||
|
4. Commit those docs: `git add CHANGELOG.md STATUS.md ROADMAP.md && git commit -m "docs: extension-restructure (Phases 3+4+6) complete; update STATUS/ROADMAP/CHANGELOG"`
|
||||||
|
5. **Ask the user for approval before tagging.**
|
||||||
|
|
||||||
|
## Your first action
|
||||||
|
|
||||||
|
Do these steps in order:
|
||||||
|
|
||||||
|
1. Read `CLAUDE.md`, then `docs/superpowers/plans/2026-05-30-extension-restructure.md`, then `extension/ARCHITECTURE.md`.
|
||||||
|
2. Emit a RELEASE STATUS block confirming you have absorbed the context (include the current main tip commit hash from `git log --oneline -1`).
|
||||||
|
3. Drain your relay inbox: `read_messages(for="pm")` — note any pending messages from devs.
|
||||||
|
4. Send a DIRECTIVE to Dev-A kicking off Phase 3, and a DIRECTIVE to Dev-B kicking off Phase 4. Both can start in parallel. Remind Dev-B that Phase 6 must wait until Phase 4 is fully merged.
|
||||||
174
docs/superpowers/coordination/v0.7-dev-a-prompt.md
Normal file
174
docs/superpowers/coordination/v0.7-dev-a-prompt.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Dev A Kickoff Prompt — v0.7.0 Plan A (Phase 3)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Plan A for the v0.7.0 "finish the extension restructure" release.
|
||||||
|
|
||||||
|
Your plan is **Phase 3 — Setup wizard SW migration + step registry** (Tasks 3.1–3.7) of the extension restructure. You move all setup-wizard crypto orchestration out of `setup.ts` and into the service worker behind three new messages (`create_vault`, `attach_vault`, `get_vault_status`), collapse the six `renderStepN`/`attachStepN` pairs into a `SetupStep` registry, and add `clearWizardState()`. `setup.ts` drops from ~1220 LOC to ≤500 and no longer imports `relicario-wasm`. This is the biggest single phase (effort: L). Phase 1 (the typed `StateHost` foundation you depend on) is already merged.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard -b phase-c-3-setup-wizard
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
|
||||||
|
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard`** before any other instruction — otherwise the subagent may commit to main.
|
||||||
|
|
||||||
|
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-a"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common pitfalls (avoid):**
|
||||||
|
|
||||||
|
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
|
||||||
|
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 3 / P1.4 only**)
|
||||||
|
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 3, Tasks 3.1–3.7**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||||
|
|
||||||
|
**Every subagent prompt MUST start with**:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
|
||||||
|
```
|
||||||
|
…before any other instruction. This is non-negotiable per project memory.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Phase 3 Tasks 3.1–3.7 — `messages.ts` additions (`create_vault`, `attach_vault`, `get_vault_status` request shapes + response interfaces + `POPUP_ONLY_TYPES`), `create_vault` + `attach_vault` SW handlers in `service-worker/vault.ts`, dispatch wiring in `service-worker/router/popup-only.ts`, WASM-stub round-out, deletion of WASM orchestration from `setup.ts`, the `SetupStep` step registry, `clearWizardState`, and the setup test updates.
|
||||||
|
|
||||||
|
**Out of scope:** Phase 4 (Dev-B owns `vault.ts` split + `vault_locked` lift) and Phase 6 (Dev-C owns the `get_vault_status` *handler*, *renderer*, and *sidebar wiring*). If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- **You own `extension/src/shared/messages.ts` for this release.** Task 3.1 adds all three new request types — including `get_vault_status`, which Dev-C (Phase 6) will *consume* but not redefine. Land Task 3.1 early so Dev-C is unblocked; tell the PM the moment it's committed/merged so they can clear Dev-C.
|
||||||
|
- You add the `create_vault` and `attach_vault` *handlers* to `service-worker/vault.ts`; Dev-C adds the `get_vault_status` handler to the same file. Coordinate via PM — your Phase 3 should merge before Dev-C's SW handler to minimize conflict on the import block / dispatch switch.
|
||||||
|
- The crypto orchestration body (embed_image_secret → unlock → register_device → manifest_encrypt for create; extract_image_secret → unlock → register_device for attach) must be copied from the *existing* `setup.ts` flow verbatim — do not invent new steps. `setup.ts` is the source of truth for the exact sequence.
|
||||||
|
- Follow Plan A's `.free()` policy: every `SessionHandle.free()` must be preceded by `wasm.lock(handle)`. The handler's `finally` block locks-then-frees only if it still owns the handle.
|
||||||
|
- Do not merge your branch to main. The PM owns merges.
|
||||||
|
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
|
||||||
|
|
||||||
|
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task. The `Notes` field narrates WHAT happened and WHY — not just "Phase X done". Three sentences max; quality over length. Print every STATUS UPDATE locally before/after sending it.
|
||||||
|
|
||||||
|
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-A
|
||||||
|
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
|
||||||
|
Branch: phase-c-3-setup-wizard
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line of message>
|
||||||
|
Tests: <green | red (which failed) | N/A>
|
||||||
|
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||||
|
```
|
||||||
|
|
||||||
|
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## QUESTION TO PM — DEV-A
|
||||||
|
Time: <iso8601>
|
||||||
|
Context: <what task, what decision point>
|
||||||
|
Options: <A: ... / B: ... / C: ...>
|
||||||
|
Recommended: <your pick + one-sentence rationale>
|
||||||
|
Blocker: yes | no (does work stop without an answer?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM via relay. Acknowledge and act.
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||||
|
|
||||||
|
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||||
|
|
||||||
|
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||||
|
- Do not create parallel implementations of an existing helper. If you write similar code twice, extract.
|
||||||
|
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
|
||||||
|
- Default to no comments unless the WHY is non-obvious.
|
||||||
|
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
## Authority within the plan
|
||||||
|
|
||||||
|
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
|
||||||
|
|
||||||
|
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging (don't fudge — debug); a discovered bug not in your plan; anything destructive; before opening the PR for review.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run the project's full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push and open the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin phase-c-3-setup-wizard
|
||||||
|
gh pr create --base main --head phase-c-3-setup-wizard --title "feat(ext): Plan C Phase 3 — setup wizard SW migration + step registry" --body "$(cat <<'EOF'
|
||||||
|
## Plan C Phase 3 — Setup wizard SW migration + step registry
|
||||||
|
|
||||||
|
Part of v0.7.0 (finish the extension restructure). Implements Phase 3 (Tasks 3.1–3.7) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
- `shared/messages.ts`: added `create_vault`, `attach_vault`, `get_vault_status` request shapes + response interfaces; +3 to `POPUP_ONLY_TYPES`.
|
||||||
|
- `service-worker/vault.ts`: `handleCreateVault` + `handleAttachVault` (SW now owns the crypto orchestration lifted from setup.ts).
|
||||||
|
- `service-worker/router/popup-only.ts`: dispatch cases for the new messages.
|
||||||
|
- `setup/setup.ts`: dropped direct WASM orchestration + `loadWasm` + `verifiedHandle`; six `renderStepN`/`attachStepN` pairs collapsed into the `SetupStep` registry; added `clearWizardState()` bound to `beforeunload` + `goto('mode')`. ~1220 LOC → ≤500.
|
||||||
|
- Tests: `service-worker/__tests__/vault.test.ts`, updated `setup/__tests__/setup.test.ts` (step-registry shape + clearWizardState).
|
||||||
|
|
||||||
|
### Coordination notes
|
||||||
|
- This PR owns the only `messages.ts` change for the release; Dev-C's Phase 6 consumes `get_vault_status` (defined here) without re-declaring it.
|
||||||
|
- Merge before Dev-C's Phase 6 SW handler to keep the `service-worker/vault.ts` import block / dispatch switch conflict-free.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
|
||||||
|
- Done-criteria greps from the plan's Task 7.1 pass (`setup.ts` ≤500 LOC, no `relicario-wasm` import, 3 dispatch cases, `clearWizardState` bound).
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-3-setup-wizard`). Then — because you own `messages.ts` which Dev-C needs — prioritize Task 3.1 and tell the PM the moment it lands. Then continue with Task 3.2.
|
||||||
173
docs/superpowers/coordination/v0.7-dev-b-prompt.md
Normal file
173
docs/superpowers/coordination/v0.7-dev-b-prompt.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Dev B Kickoff Prompt — v0.7.0 Plan B (Phase 4)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Plan B for the v0.7.0 "finish the extension restructure" release.
|
||||||
|
|
||||||
|
Your plan is **Phase 4 — Split `vault.ts` + lift `vault_locked` channel** (Tasks 4.1–4.7) of the extension restructure. You split the 1037-LOC `vault.ts` monolith into 5 focused modules — `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts` — trimming `vault.ts` to ≤~250 LOC of routing + state, add the debounced sidebar search, and lift the `vault_locked` RPC intercept out of `vault.ts` into `shared/state.ts`'s `sendMessage` wrapper (whose signature Phase 1 already laid). Effort: M. Phase 1 (the typed `StateHost` foundation) is already merged.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split -b phase-c-4-vault-split
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
|
||||||
|
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split`** before any other instruction — otherwise the subagent may commit to main.
|
||||||
|
|
||||||
|
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-b"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common pitfalls (avoid):**
|
||||||
|
|
||||||
|
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
|
||||||
|
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 4 / P1.5 only**)
|
||||||
|
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 4, Tasks 4.1–4.7**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||||
|
|
||||||
|
**Every subagent prompt MUST start with**:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
|
||||||
|
```
|
||||||
|
…before any other instruction. This is non-negotiable per project memory.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Phase 4 Tasks 4.1–4.7 — create `vault-shell.ts`, `vault-sidebar.ts` (with the 80ms debounced search per DEV-C P2), `vault-list.ts`, `vault-drawer.ts` (incl. `ensureDrawerClosedForRoute` + drawer auto-close on non-list nav), `vault-form-wrapper.ts`; trim `vault.ts` to routing + state ≤~250 LOC; remove the `vault_locked` intercept from `vault.ts` and fill the body of `shared/state.ts`'s `sendMessage` wrapper with it; the drawer-state + (any vault) tests.
|
||||||
|
|
||||||
|
**Out of scope:** Phase 3 (Dev-A owns `setup.ts` + `messages.ts` + the `create_vault`/`attach_vault` SW handlers) and Phase 6 (Dev-C owns `get_vault_status` + the `vault-status.ts` renderer + its sidebar-footer wiring). If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- **You create `extension/src/vault/vault-sidebar.ts`. Dev-C (Phase 6, Task 6.3) will later modify it to wire the status indicator into the sidebar footer.** To make that handoff clean, when you build `vault-sidebar.ts`, include a clearly-labelled footer slot in the sidebar markup (an empty `<div id="vault-status-slot"></div>` inside a `vault-sidebar__footer` element is fine) even though you don't populate it — leave a one-line comment that Phase 6 wires it. Tell the PM the moment Phase 4 is REVIEW-READY/merged so Dev-C can start Task 6.3.
|
||||||
|
- The `vault_locked` intercept logic is *moved*, not rewritten: lift the exact behavior from `vault.ts` (the pre-Phase-4 RPC intercept) into `sendMessage` in `shared/state.ts`. After the move, `grep -c "vault_locked" extension/src/vault/vault.ts` must return 0.
|
||||||
|
- Each module extraction is a no-behavior-change refactor — run `npx vitest run` after each and keep it green. Paste function bodies verbatim from `vault.ts`; don't redesign them.
|
||||||
|
- Do not touch `shared/messages.ts` — that's Dev-A's file for this release. If you think you need a message change, escalate to PM.
|
||||||
|
- Do not merge your branch to main. The PM owns merges.
|
||||||
|
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
|
||||||
|
|
||||||
|
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task. The `Notes` field narrates WHAT happened and WHY. Three sentences max; quality over length. Print every STATUS UPDATE locally before/after sending it.
|
||||||
|
|
||||||
|
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")` first, then post via `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
|
||||||
|
Branch: phase-c-4-vault-split
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line of message>
|
||||||
|
Tests: <green | red (which failed) | N/A>
|
||||||
|
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||||
|
```
|
||||||
|
|
||||||
|
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## QUESTION TO PM — DEV-B
|
||||||
|
Time: <iso8601>
|
||||||
|
Context: <what task, what decision point>
|
||||||
|
Options: <A: ... / B: ... / C: ...>
|
||||||
|
Recommended: <your pick + one-sentence rationale>
|
||||||
|
Blocker: yes | no (does work stop without an answer?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**You'll receive**: `## DIRECTIVE TO DEV-B` blocks from the PM via relay. Acknowledge and act.
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||||
|
|
||||||
|
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||||
|
|
||||||
|
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes.
|
||||||
|
- Do not create parallel implementations of an existing helper. If you write similar code twice, extract.
|
||||||
|
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
|
||||||
|
- Default to no comments unless the WHY is non-obvious.
|
||||||
|
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
## Authority within the plan
|
||||||
|
|
||||||
|
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
|
||||||
|
|
||||||
|
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging; a discovered bug not in your plan; anything destructive; before opening the PR for review.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run the project's full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push and open the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin phase-c-4-vault-split
|
||||||
|
gh pr create --base main --head phase-c-4-vault-split --title "refactor(ext): Plan C Phase 4 — split vault.ts + lift vault_locked channel" --body "$(cat <<'EOF'
|
||||||
|
## Plan C Phase 4 — Split vault.ts + lift vault_locked channel
|
||||||
|
|
||||||
|
Part of v0.7.0 (finish the extension restructure). Implements Phase 4 (Tasks 4.1–4.7) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
- Split the 1037-LOC `vault/vault.ts` into 5 modules: `vault-shell.ts` (DOM scaffolding + color-scheme + onMessage), `vault-sidebar.ts` (categories nav + 80ms debounced search + bottom nav + footer status slot), `vault-list.ts` (list/row rendering), `vault-drawer.ts` (open/close/render + `ensureDrawerClosedForRoute`), `vault-form-wrapper.ts` (`renderFormWrapped` + sticky bar + header).
|
||||||
|
- `vault.ts` trimmed to ≤~250 LOC of routing + state.
|
||||||
|
- Lifted the `vault_locked` RPC intercept out of `vault.ts` into `shared/state.ts`'s `sendMessage` wrapper (Phase 1 laid the signature; this fills the body).
|
||||||
|
- Tests: `vault/__tests__/drawer-state.test.ts` (drawer auto-close on navigation) + state `vault_locked` channel coverage.
|
||||||
|
|
||||||
|
### Coordination notes
|
||||||
|
- `vault-sidebar.ts` ships with an empty footer status slot (`#vault-status-slot`); Dev-C's Phase 6 Task 6.3 wires the indicator into it. Merge this PR before Dev-C's wiring commit.
|
||||||
|
- No `messages.ts` changes (that's Dev-A's file this release).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
|
||||||
|
- Done-criteria greps from the plan's Task 7.1 pass (5 `vault-*.ts` modules, `vault.ts` ≤~250 LOC, `vault_locked` count 0 in vault.ts, `SEARCH_DEBOUNCE_MS` present).
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-4-vault-split`), then start Task 4.1. Remember to leave the footer status slot in `vault-sidebar.ts` for Dev-C, and ping the PM when you're REVIEW-READY so Dev-C can begin Task 6.3.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Dev-C ARCHITECTURE.md slice — Plan C Phase 6 (`get_vault_status` + sidebar status indicator)
|
||||||
|
|
||||||
|
Ready-to-fold additions for `extension/ARCHITECTURE.md`, scoped to Dev-C's Phase 6 work only.
|
||||||
|
Phase 3 (`create_vault`/`attach_vault`, setup-SW migration) and Phase 4 (the `vault.ts` →
|
||||||
|
`vault-shell`/`vault-sidebar`/`vault-list`/`vault-drawer`/`vault-form-wrapper` split) doc updates
|
||||||
|
are Dev-A's / Dev-B's slices — not included here.
|
||||||
|
|
||||||
|
Merged to origin/main as `397cc78` (Merge Plan C Phase 6). Local source ref: `675452a`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SW message-protocol row — `get_vault_status` (read-only, popup-only)
|
||||||
|
|
||||||
|
**Where:** the `router/popup-only.ts` bullet in the service-worker module map (around line 270),
|
||||||
|
and/or wherever the read-only popup messages are enumerated.
|
||||||
|
|
||||||
|
**Add:**
|
||||||
|
|
||||||
|
> - `get_vault_status` (popup-only, read-only) — returns the cached sync summary
|
||||||
|
> `{ ahead, behind, lastSyncAt, pendingItems }` with **no network call**. `ahead`/`behind`/
|
||||||
|
> `lastSyncAt` are read straight off `state.gitHost` (populated by the `sync` handler, which
|
||||||
|
> records `lastSyncAt = Math.floor(Date.now()/1000)` — unix **seconds** — after a successful
|
||||||
|
> manifest fetch). `pendingItems` is a live count of active (non-trashed) manifest entries via
|
||||||
|
> `vault.listItems(manifest).length`. `ahead`/`behind` are structurally always `0` in the
|
||||||
|
> extension (it writes straight to the host via the Contents REST API; there is no local commit
|
||||||
|
> graph) and exist for parity with `relicario status`. Handler: `vault.handleGetVaultStatus(state)`
|
||||||
|
> — synchronous; its `Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks the
|
||||||
|
> `PopupState` import cycle and structurally forbids it from making a network call.
|
||||||
|
|
||||||
|
## 2. `git-host.ts` cache fields
|
||||||
|
|
||||||
|
**Where:** the `git-host.ts` bullet in the SW module map (around line 299, listing the interface methods).
|
||||||
|
|
||||||
|
**Amend** the interface description to note the cached sync metadata:
|
||||||
|
|
||||||
|
> The `GitHost` interface also carries cached sync metadata —
|
||||||
|
> `lastSyncAt: number | null` (unix seconds), `ahead: number`, `behind: number` — initialized to
|
||||||
|
> `null`/`0`/`0` in both `GiteaHost` and `GitHubHost`. The cache rides the gitHost lifecycle: it is
|
||||||
|
> created on unlock and cleared whenever `state.gitHost` is nulled — on session-timer expiry
|
||||||
|
> (`index.ts`) **and** on the explicit `lock` message handler (`popup-only.ts`), which now nulls
|
||||||
|
> `state.gitHost` symmetrically so a lock→unlock cycle can't surface a stale `lastSyncAt`.
|
||||||
|
|
||||||
|
## 3. Sidebar status-indicator UI flow
|
||||||
|
|
||||||
|
**Where:** the `src/vault/` module map (around line 184). Add a `vault-status.ts` entry and a note on
|
||||||
|
the `vault-sidebar.ts` footer wiring. (If Dev-B's Phase 4 slice has already added the `vault-sidebar.ts`
|
||||||
|
entry, fold the status note into it rather than duplicating.)
|
||||||
|
|
||||||
|
**Add:**
|
||||||
|
|
||||||
|
> - `vault-status.ts` — sidebar-footer sync indicator renderer. `renderStatusIndicator(el, status)`
|
||||||
|
> is pure DOM: it renders, by priority, `N pending` / `N ahead` / `N behind`, falling back to
|
||||||
|
> `in sync`, plus a `last sync <relativeTime>` / `never synced` line. Reuses `shared/glyphs.ts`
|
||||||
|
> (`GLYPH_PENDING`/`AHEAD`/`BEHIND`/`SYNCED`) and `shared/relative-time.ts`. `VaultStatus` is an
|
||||||
|
> alias of `GetVaultStatusResponse['data']`, so the renderer's input shape is single-sourced from
|
||||||
|
> the message contract and can't drift from the SW handler.
|
||||||
|
> - **Status-indicator flow** (in the `vault-sidebar.ts` entry): the footer holds a
|
||||||
|
> `#vault-status-slot` plus a manual `↻` refresh button (`GLYPH_REFRESH`). `wireSidebar` calls
|
||||||
|
> `refreshStatus()` once on mount and again on the button's click — sending `get_vault_status` via
|
||||||
|
> `ctx.sendMessage` and rendering the result into the slot. There is **no timer polling**: the
|
||||||
|
> indicator only refreshes on mount + explicit button press, matching the spec's
|
||||||
|
> no-network-without-user-intent discipline (sync is user-initiated).
|
||||||
|
|
||||||
|
## 4. Living-docs note
|
||||||
|
|
||||||
|
This closes the last `relicario status` CLI/extension parity gap (called out in the extension
|
||||||
|
restructure spec, `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`). `STATUS.md`
|
||||||
|
should move the extension-restructure line to shipped as part of the Task 7.1 pass.
|
||||||
178
docs/superpowers/coordination/v0.7-dev-c-prompt.md
Normal file
178
docs/superpowers/coordination/v0.7-dev-c-prompt.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Dev C Kickoff Prompt — v0.7.0 Plan C (Phase 6)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Plan C for the v0.7.0 "finish the extension restructure" release.
|
||||||
|
|
||||||
|
Your plan is **Phase 6 — `get_vault_status` SW handler + sidebar status indicator** (Tasks 6.1–6.3) of the extension restructure. You add the `get_vault_status` service-worker handler (returning cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` plus a live `pendingItems` count — no network call), build the `vault-status.ts` renderer for the sidebar-footer indicator, and wire it into the sidebar (refresh on mount + a manual ↻ button, **no timer polling**). This closes the last `relicario status` CLI/extension parity gap. Effort: S-M.
|
||||||
|
|
||||||
|
**⚠️ Your phase has cross-stream dependencies — read the coordination rules carefully.** Phase 6 depends on Phase 3 (Dev-A) for the `get_vault_status` message type and on Phase 4 (Dev-B) for the `vault-sidebar.ts` module you wire into.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status -b phase-c-6-vault-status
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||||
|
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`** before any other instruction — otherwise the subagent may commit to main.
|
||||||
|
|
||||||
|
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-c"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common pitfalls (avoid):**
|
||||||
|
|
||||||
|
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
|
||||||
|
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 6 only**)
|
||||||
|
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 6, Tasks 6.1–6.3**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||||
|
|
||||||
|
**Every subagent prompt MUST start with**:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||||
|
```
|
||||||
|
…before any other instruction. This is non-negotiable per project memory.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Phase 6 Tasks 6.1–6.3 — `handleGetVaultStatus` in `service-worker/vault.ts` + cached `ahead`/`behind`/`lastSyncAt` fields on the git-host state + populating them in the `sync` handler + dispatch wiring in `popup-only.ts`; the `vault-status.ts` renderer + any new glyphs in `shared/glyphs.ts`; wiring the indicator into `vault-sidebar.ts`'s footer (mount + manual refresh). Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
|
||||||
|
|
||||||
|
**Out of scope:** Phase 3 (Dev-A owns `setup.ts`, ALL of `messages.ts`, and the `create_vault`/`attach_vault` handlers) and Phase 4 (Dev-B owns the `vault.ts` split, including *creating* `vault-sidebar.ts`). You only *modify* `vault-sidebar.ts` to add the wiring in Task 6.3. If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||||
|
|
||||||
|
**Hard rules — sequencing (this is the crux of your phase):**
|
||||||
|
|
||||||
|
- **Do NOT touch `shared/messages.ts`.** Dev-A (Phase 3, Task 3.1) defines the `get_vault_status` request type + `GetVaultStatusResponse` interface. You *import* `GetVaultStatusResponse` from `../shared/messages`; you never declare it. **Before you can compile Task 6.1, Dev-A's Task 3.1 must have landed on main** (or be available to merge). Confirm with the PM at kickoff. If it hasn't landed, ask the PM whether to wait or to proceed against a temporary local type and reconcile at merge — prefer waiting if Dev-A is close.
|
||||||
|
- **Stage your tasks 6.1 → 6.2 → 6.3.** Tasks 6.1 (SW handler) and 6.2 (renderer) are independent of Phase 4 and you can build them as soon as the `get_vault_status` type exists. **Task 6.3 wires into `vault-sidebar.ts`, which Dev-B (Phase 4) creates — you MUST wait for Dev-B's Phase 4 PR to merge before doing Task 6.3.** Ask the PM to confirm Phase 4 is merged, then pull main into your branch and do the wiring. Dev-B has been told to leave an empty `#vault-status-slot` footer element for you.
|
||||||
|
- Your `get_vault_status` handler is additive in `service-worker/vault.ts` alongside Dev-A's `create_vault`/`attach_vault` handlers. Expect a possible small merge conflict on the import block / dispatch switch in `service-worker/vault.ts` + `popup-only.ts`; the PM will sequence your SW handler merge after Dev-A's Phase 3.
|
||||||
|
- **No network in `get_vault_status`** — return cached state only. The spec is explicit: sync is user-initiated. **No timer polling** in the wiring — refresh on mount + manual ↻ button only.
|
||||||
|
- Do not merge your branch to main. The PM owns merges.
|
||||||
|
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
|
||||||
|
|
||||||
|
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task, **and especially when you are blocked waiting on Dev-A's or Dev-B's merge** (so the PM knows your idle is a dependency wait, not a stall). The `Notes` field narrates WHAT happened and WHY. Three sentences max. Print every STATUS UPDATE locally before/after sending it.
|
||||||
|
|
||||||
|
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-C
|
||||||
|
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
|
||||||
|
Branch: phase-c-6-vault-status
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line of message>
|
||||||
|
Tests: <green | red (which failed) | N/A>
|
||||||
|
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||||
|
```
|
||||||
|
|
||||||
|
**When you need PM input mid-task** (e.g. "is Phase 3's `get_vault_status` type merged yet?" / "is Phase 4 merged so I can do 6.3?"): post via `post_message(kind="question")` with format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## QUESTION TO PM — DEV-C
|
||||||
|
Time: <iso8601>
|
||||||
|
Context: <what task, what decision point>
|
||||||
|
Options: <A: ... / B: ... / C: ...>
|
||||||
|
Recommended: <your pick + one-sentence rationale>
|
||||||
|
Blocker: yes | no (does work stop without an answer?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**You'll receive**: `## DIRECTIVE TO DEV-C` blocks from the PM via relay. Acknowledge and act.
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||||
|
|
||||||
|
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||||
|
|
||||||
|
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes.
|
||||||
|
- Do not create parallel implementations of an existing helper (reuse `shared/relative-time.ts` for the timestamp; reuse the existing glyph family in `shared/glyphs.ts`).
|
||||||
|
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
|
||||||
|
- Default to no comments unless the WHY is non-obvious.
|
||||||
|
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
## Authority within the plan
|
||||||
|
|
||||||
|
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
|
||||||
|
|
||||||
|
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging; a discovered bug not in your plan; anything destructive; **the dependency waits (Phase 3 type / Phase 4 sidebar)**; before opening the PR for review.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run the project's full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push and open the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin phase-c-6-vault-status
|
||||||
|
gh pr create --base main --head phase-c-6-vault-status --title "feat(ext): Plan C Phase 6 — get_vault_status + sidebar status indicator" --body "$(cat <<'EOF'
|
||||||
|
## Plan C Phase 6 — get_vault_status + sidebar status indicator
|
||||||
|
|
||||||
|
Part of v0.7.0 (finish the extension restructure). Implements Phase 6 (Tasks 6.1–6.3) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`. Closes the `relicario status` CLI/extension parity gap.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
- `service-worker/vault.ts`: `handleGetVaultStatus` — returns cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` + live `pendingItems` from the manifest. No network call.
|
||||||
|
- `service-worker/git-host.ts`: cached `lastSyncAt`/`ahead`/`behind` fields, populated by the `sync` handler.
|
||||||
|
- `service-worker/router/popup-only.ts`: `get_vault_status` dispatch case.
|
||||||
|
- `vault/vault-status.ts`: sidebar-footer indicator renderer (in sync / N ahead / N behind / N pending / never synced); reuses `shared/relative-time.ts` + glyph family.
|
||||||
|
- `vault/vault-sidebar.ts`: wired the indicator into the footer slot — refresh on mount + manual ↻ button, no timer polling.
|
||||||
|
- Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
|
||||||
|
|
||||||
|
### Coordination notes
|
||||||
|
- Consumes the `get_vault_status` message type defined by Dev-A's Phase 3 (`messages.ts`); does not redefine it.
|
||||||
|
- Task 6.3 wiring lands on top of Dev-B's Phase 4 `vault-sidebar.ts` (merged first).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
|
||||||
|
- Done-criteria greps from the plan's Task 7.1 pass (`get_vault_status` dispatched + rendered, no network in handler, no polling timer).
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-6-vault-status`). Then immediately ask the PM (via `## QUESTION TO PM`) whether Dev-A's Phase 3 `get_vault_status` type has landed yet — that gates Task 6.1. While you wait, you can prepare the Task 6.2 renderer (`vault-status.ts`) since it only needs the local `VaultStatus` shape, not `messages.ts`.
|
||||||
68
docs/superpowers/coordination/v0.7-launch.sh
Executable file
68
docs/superpowers/coordination/v0.7-launch.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Auto-generated by multi-agent-kickoff — v0.7.0 (finish the extension restructure)
|
||||||
|
# Streams: Dev-A = Phase 3, Dev-B = Phase 4, Dev-C = Phase 6
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO="/home/alee/Sources/relicario"
|
||||||
|
RELAY_DIR="$REPO/tools/relay"
|
||||||
|
COORD="$REPO/docs/superpowers/coordination"
|
||||||
|
RELEASE="v0.7"
|
||||||
|
SESSION="$RELEASE"
|
||||||
|
|
||||||
|
# ── 1. Relay ─────────────────────────────────────────────────────────────
|
||||||
|
if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then
|
||||||
|
echo "[relay] already running on :7331"
|
||||||
|
else
|
||||||
|
echo "[relay] starting..."
|
||||||
|
cd "$RELAY_DIR"
|
||||||
|
nohup npx tsx server.ts > /tmp/relay-v0.7.log 2>&1 &
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
sleep 1
|
||||||
|
if curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1; then
|
||||||
|
echo "[relay] ready on :7331"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 10 ]; then
|
||||||
|
echo "[relay] ERROR: failed to start — check /tmp/relay-v0.7.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. tmux session ──────────────────────────────────────────────────────
|
||||||
|
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||||
|
echo "[tmux] session '$SESSION' already exists — attaching"
|
||||||
|
exec tmux attach-session -t "$SESSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[tmux] creating session '$SESSION'..."
|
||||||
|
tmux new-session -d -s "$SESSION" -n "PM"
|
||||||
|
tmux send-keys -t "$SESSION:PM" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-A"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-A" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-B"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-B" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-C"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-C" "claude" Enter
|
||||||
|
|
||||||
|
tmux select-window -t "$SESSION:PM"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ v0.7.0 — finish the extension restructure — prompt cheatsheet ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════════╣"
|
||||||
|
echo "║ PM window → paste $COORD/v0.7-pm-prompt.md ║"
|
||||||
|
echo "║ Dev-A window → paste $COORD/v0.7-dev-a-prompt.md ║"
|
||||||
|
echo "║ Dev-B window → paste $COORD/v0.7-dev-b-prompt.md ║"
|
||||||
|
echo "║ Dev-C window → paste $COORD/v0.7-dev-c-prompt.md ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════════╣"
|
||||||
|
echo "║ A = Phase 3 (setup wizard SW migration) ║"
|
||||||
|
echo "║ B = Phase 4 (split vault.ts + vault_locked lift) ║"
|
||||||
|
echo "║ C = Phase 6 (get_vault_status + status indicator) — deps on A & B ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "[tmux] attaching — use Ctrl-b n / Ctrl-b p to switch windows"
|
||||||
|
exec tmux attach-session -t "$SESSION"
|
||||||
129
docs/superpowers/coordination/v0.7-pm-prompt.md
Normal file
129
docs/superpowers/coordination/v0.7-pm-prompt.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# PM Kickoff Prompt — v0.7.0 finish the extension restructure
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the **project manager** for the v0.7.0 "finish the extension restructure" release. 3 senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all 3+1 terminals and relays messages between them.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Working directory: `/home/alee/Sources/relicario`
|
||||||
|
- Branch: stay on `main`. Do not check out feature branches.
|
||||||
|
- Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"pm"}'
|
||||||
|
```
|
||||||
|
The shim connects over HTTP and has the same semantics as the MCP tools.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — the bundle spec
|
||||||
|
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — the implementation plan. **Phases 1, 2, 5 already merged (2026-05-30).** This release finishes the remaining three:
|
||||||
|
- Plan A (Dev-A) → **Phase 3** (Tasks 3.1–3.7): setup wizard SW migration + step registry + `clearWizardState`
|
||||||
|
- Plan B (Dev-B) → **Phase 4** (Tasks 4.1–4.7): split `vault.ts` into 5 modules + lift the `vault_locked` channel into `shared/state.ts`
|
||||||
|
- Plan C (Dev-C) → **Phase 6** (Tasks 6.1–6.3): `get_vault_status` SW handler + sidebar status indicator
|
||||||
|
|
||||||
|
## Your authority
|
||||||
|
|
||||||
|
- Approve or deny scope changes from devs
|
||||||
|
- Review and merge PRs from each dev's feature branch
|
||||||
|
- Drive any release-prep work that isn't a feature plan (Task 7.1 final verification sweep, CHANGELOG, version bumps to v0.7.0, STATUS.md / ROADMAP.md updates) — this is your hands-on work
|
||||||
|
- Tag `v0.7.0` once everything is integrated **— but only after explicit user approval**
|
||||||
|
|
||||||
|
## Your boundaries
|
||||||
|
|
||||||
|
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` / STATUS / ROADMAP are fine.
|
||||||
|
- Don't deviate from the spec without user approval.
|
||||||
|
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||||
|
- Don't tag without user approval.
|
||||||
|
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`).
|
||||||
|
|
||||||
|
## ⚠️ Critical: cross-stream dependencies (the whole reason you exist this release)
|
||||||
|
|
||||||
|
Per the plan's "Notes on execution order": **Phase 4 blocks Phase 6, and Phase 3 owns a file Phase 6 needs.** Your central job is sequencing the merges and arbitrating the two shared edits:
|
||||||
|
|
||||||
|
1. **`extension/src/shared/messages.ts`** — Dev-A (Phase 3, Task 3.1) adds all three new request types: `create_vault`, `attach_vault`, **and `get_vault_status`**, plus their response interfaces, plus the three additions to `POPUP_ONLY_TYPES`. Dev-C (Phase 6) *consumes* `get_vault_status` but must NOT redefine it. **Directive at kickoff:** Dev-A owns every `messages.ts` change; Dev-C imports `GetVaultStatusResponse` from `messages.ts` and does not touch that file. If Dev-C starts before Dev-A's Task 3.1 lands, have Dev-C either (a) wait on the type, or (b) work against a local type alias and you reconcile at merge — prefer (a) if Dev-A is close.
|
||||||
|
|
||||||
|
2. **`extension/src/vault/vault-sidebar.ts`** — Dev-B (Phase 4, Task 4.2) *creates* this file. Dev-C (Phase 6, Task 6.3) *modifies* it to wire the status indicator into the sidebar footer. **Directive:** Dev-C should land Tasks 6.1 (SW handler) and 6.2 (renderer `vault-status.ts`) — both independent of Phase 4 — first, then HOLD on Task 6.3 until Dev-B's Phase 4 PR merges. Sequence the merges: **Phase 4 merges before Phase 6's wiring commit.**
|
||||||
|
|
||||||
|
3. **`extension/src/service-worker/vault.ts`** — Dev-A (Phase 3: `create_vault` / `attach_vault` handlers) and Dev-C (Phase 6: `get_vault_status` handler) both append handlers here, and both add a dispatch case to `service-worker/router/popup-only.ts`. These are additive and shouldn't conflict, but you may get a small merge conflict on the import block / switch statement. Merge Dev-A (Phase 3) before Dev-C's SW handler if possible to minimize churn. A trivial conflict here is expected — resolve it at merge or have the second dev rebase.
|
||||||
|
|
||||||
|
**Recommended merge order:** Phase 3 (Dev-A) → Phase 4 (Dev-B) → Phase 6 (Dev-C). Confirm this with the devs at kickoff so Dev-C knows to stage 6.1/6.2 early and 6.3 last.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of 3+1 terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action. If the relay MCP tools are not registered in your session, fall back to the Python shim (see **Relay server** section above) or ask the user to relay manually.
|
||||||
|
|
||||||
|
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is the PM terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly so the user sees the reasoning, not just the verdict. When you dispatch a subagent (e.g. for plan review or coherence pass), say so. One or two sentences per beat is plenty — the goal is for the user to read this terminal top-to-bottom and understand the release as a story.
|
||||||
|
|
||||||
|
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks, either from the relay inbox or relayed by the user if the relay is down.
|
||||||
|
|
||||||
|
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## DIRECTIVE TO DEV-<letter>
|
||||||
|
Time: <iso8601>
|
||||||
|
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||||
|
Notes: <one paragraph max>
|
||||||
|
Next: <one concrete instruction or "continue plan">
|
||||||
|
```
|
||||||
|
|
||||||
|
When asked "status?" by the user at any time, give a current rollup:
|
||||||
|
|
||||||
|
```
|
||||||
|
## RELEASE STATUS — v0.7.0
|
||||||
|
Devs: <per-dev one-line state>
|
||||||
|
PM: <what you're working on>
|
||||||
|
Blockers: <list, or "none">
|
||||||
|
Next milestone: <e.g., "Dev A REVIEW-READY", "tag v0.7.0">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reviewing PRs
|
||||||
|
|
||||||
|
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||||
|
1. `gh pr view <url>` to read description and CI status
|
||||||
|
2. `gh pr diff <url>` to read changes
|
||||||
|
3. Check the diff against the spec and plan acceptance criteria (the plan's "Final Verification" Task 7.1 lists the exact done-criteria greps — use them)
|
||||||
|
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash — project rule: git history is the audit log)
|
||||||
|
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
|
||||||
|
|
||||||
|
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving.
|
||||||
|
|
||||||
|
## Pre-tag checklist
|
||||||
|
|
||||||
|
Before tagging `v0.7.0`:
|
||||||
|
|
||||||
|
- [ ] Every dev branch merged to main (Phases 3, 4, 6)
|
||||||
|
- [ ] Task 7.1 done-criteria sweep passes (all greps in the plan's Final Verification section)
|
||||||
|
- [ ] `cd extension && npx tsc --noEmit` clean
|
||||||
|
- [ ] `cd extension && npx vitest run` green (baseline was 389/389 + new Phase 3/4/6 tests)
|
||||||
|
- [ ] `cd extension && npm run build:all` clean (only the pre-existing 4MB WASM warning)
|
||||||
|
- [ ] `cargo test` still green (these phases don't touch Rust, but confirm no accidental breakage)
|
||||||
|
- [ ] STATUS.md + ROADMAP.md moved extension restructure to "Shipped"; CHANGELOG.md v0.7.0 entry written; version bumped to v0.7.0
|
||||||
|
- [ ] User-driven smoke test of the merged result
|
||||||
|
- [ ] Explicit user approval to tag
|
||||||
|
|
||||||
|
After all PRs merge, run the project's cleanup (CLAUDE.md rule #6): `Workflow({name:"release", args:{action:"cleanup"}})` to remove this lift's worktrees and branches. **Note:** there are also stale `phase-c-1/2/5` worktrees from the previous lift (under `.worktrees/`) that were never cleaned up — flag this to the user; they may want them removed too (destructive op → ask first).
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||||
|
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the spec, the plan, and the cross-stream dependency map above.
|
||||||
|
3. Send opening directives to all three devs via `post_message` — at minimum: (a) confirm Dev-A owns ALL of `messages.ts`, (b) confirm the merge order Phase 3 → Phase 4 → Phase 6, (c) tell Dev-C to stage Tasks 6.1/6.2 first and HOLD 6.3 until Phase 4 merges.
|
||||||
|
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing them to proceed.
|
||||||
134
docs/superpowers/coordination/v0.8.1-dev-a-prompt.md
Normal file
134
docs/superpowers/coordination/v0.8.1-dev-a-prompt.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Dev A Kickoff Prompt — v0.8.1 Stream A (shared item-build foundation)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Stream A for the v0.8.1 "org item-type parity" release.
|
||||||
|
|
||||||
|
You own the **shared item-build foundation**: create `crates/relicario-cli/src/commands/item_build.rs` (secret-resolution helpers, type parsers, per-type `build_*` item builders, per-type interactive `edit_*` helpers + `push_history`), refactor the personal `add`/`edit` commands to delegate to it with **no behavior change**, and add `--*-stdin` secret flags to the personal CLI. **Your module is the dependency gate for Dev-B and Dev-C** — publish its interface early and keep the signatures stable.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with Dev-B, Dev-C, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git branch --list feature/v0.8.1-dev-a-foundation # ensure no collision; escalate if it exists
|
||||||
|
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-a -b feature/v0.8.1-dev-a-foundation
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-a
|
||||||
|
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-a
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-a`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-a` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. This is non-negotiable.
|
||||||
|
|
||||||
|
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — your `from` is always `"dev-a"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-a")`. After any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback** (relay tools not registered):
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-a"}'
|
||||||
|
```
|
||||||
|
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
|
||||||
|
|
||||||
|
## Relay polling cadence — MANDATORY (do NOT go head-down)
|
||||||
|
|
||||||
|
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task.
|
||||||
|
|
||||||
|
**Call `read_messages(for="dev-a")` (run `list_pending(for="dev-a")` first if you want a cheap check) at ALL of these points:**
|
||||||
|
- Before dispatching EACH subagent — and again the moment it returns.
|
||||||
|
- Before EACH commit, and at the start + end of every task/step.
|
||||||
|
- Any time you've been heads-down for more than a few minutes.
|
||||||
|
|
||||||
|
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.1 shared module + §Design.2/.3 personal `--*-stdin`**)
|
||||||
|
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-A** section, Tasks A1–A4, task by task
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-a
|
||||||
|
```
|
||||||
|
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Tasks A1 (shared module scaffold: secret resolution + parsers), A2 (move interactive `edit_*` helpers + `push_history`), A3 (move the seven `build_*` builders; personal `cmd_add` delegates), A4 (personal `--*-stdin` flags + CLI ARCHITECTURE doc).
|
||||||
|
|
||||||
|
**Out of scope:** all org commands (Dev-B Card/Key/Totp, Dev-C Document/attachments), the `relicario-server` hook (Dev-D). If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- **A is behavior-preserving for the personal vault.** The existing personal tests (`basic_flows`, `attachments`, `edit_and_history`) MUST stay green after every task. Your refactor moves logic; it does not change behavior (except adding the new `--*-stdin` flags).
|
||||||
|
- **Your public interface is a contract.** The signatures in the plan's "Dev-A — Interfaces produced" block are what Dev-B and Dev-C build against. Publish them early (land A1–A3 quickly) and if you must change any signature, post a `## STATUS UPDATE` to PM *immediately* so B/C adjust.
|
||||||
|
- Do not merge your branch — the PM merges (you're first in the merge order).
|
||||||
|
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
|
||||||
|
|
||||||
|
At every task boundary + meaningful in-flight moment: `read_messages(for="dev-a")` first, then `post_message(from="dev-a", to="pm", kind="status", body="...")`. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-A
|
||||||
|
Time: <iso8601>
|
||||||
|
Branch: feature/v0.8.1-dev-a-foundation
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line>
|
||||||
|
Tests: <green | red (which) | N/A>
|
||||||
|
Notes: <≤3 sentences>
|
||||||
|
```
|
||||||
|
|
||||||
|
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-A` (Context / Options / Recommended / Blocker: yes|no).
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed, no per-edit confirmations. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
|
||||||
|
|
||||||
|
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (catch duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. No parallel implementations of an existing helper. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
|
||||||
|
|
||||||
|
## Escalate to PM when
|
||||||
|
|
||||||
|
A scope question outside the plan; a test you can't green after honest debugging; a discovered bug not in your plan; anything destructive; before REVIEW-READY.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run full validation from the worktree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p relicario-cli
|
||||||
|
cargo build -p relicario-cli
|
||||||
|
cargo clippy -p relicario-cli --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push your branch (this project uses Gitea; the **PM merges via git**, so you do NOT open a GitHub PR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/v0.8.1-dev-a-foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log` (never a guessed SHA).
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-a-foundation`, plan absorbed), acknowledge you are the dependency gate for B/C, then start Task A1.
|
||||||
134
docs/superpowers/coordination/v0.8.1-dev-b-prompt.md
Normal file
134
docs/superpowers/coordination/v0.8.1-dev-b-prompt.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Dev B Kickoff Prompt — v0.8.1 Stream B (org Card/Key/Totp parity)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Stream B for the v0.8.1 "org item-type parity" release.
|
||||||
|
|
||||||
|
You own **org `add`/`edit` parity for Card, Key, and Totp**: extend `commands::org::OrgAddKind` + the `main.rs` clap surface with those three types, wire them to Dev-A's shared builders, convert org `edit` to per-type interactive dispatch (reusing Dev-A's `edit_*` helpers), and add the `org_items` integration tests. You establish the **org per-type dispatch skeleton** in `commands/org.rs` that Dev-C later extends with Document.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with Dev-A, Dev-C, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git branch --list feature/v0.8.1-dev-b-card-key-totp # ensure no collision; escalate if it exists
|
||||||
|
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-b -b feature/v0.8.1-dev-b-card-key-totp
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-b
|
||||||
|
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-b
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-b`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-b` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
|
||||||
|
|
||||||
|
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — your `from` is always `"dev-b"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-b")`. After any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback** (relay tools not registered):
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-b"}'
|
||||||
|
```
|
||||||
|
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
|
||||||
|
|
||||||
|
## Relay polling cadence — MANDATORY (do NOT go head-down)
|
||||||
|
|
||||||
|
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task.
|
||||||
|
|
||||||
|
**Call `read_messages(for="dev-b")` (run `list_pending(for="dev-b")` first if you want a cheap check) at ALL of these points:**
|
||||||
|
- Before dispatching EACH subagent — and again the moment it returns.
|
||||||
|
- Before EACH commit, and at the start + end of every task/step.
|
||||||
|
- Any time you've been heads-down for more than a few minutes.
|
||||||
|
|
||||||
|
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.2/.3, the Card/Key/Totp slice of org add/edit**)
|
||||||
|
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-B** section, Tasks B1–B4, task by task. Also read the **Dev-A — Interfaces produced** block: that is the contract you build against.
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-b
|
||||||
|
```
|
||||||
|
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Tasks B1 (extend `commands::org::OrgAddKind` + `build_org_item` to delegate to Dev-A's builders for Card/Key/Totp), B2 (`main.rs` clap `OrgAddKind` Card/Key/Totp variants + `--*-stdin` flags + dispatch), B3 (convert `run_edit` to per-type interactive dispatch via shared `edit_*` helpers), B4 (`org_items` round-trip tests for Card/Key/Totp).
|
||||||
|
|
||||||
|
**Out of scope:** Dev-A's shared module itself, Dev-C's Document/attachment work, Dev-D's `relicario-server` hook. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- **You consume Dev-A's `crate::commands::item_build`.** Do NOT duplicate builder/edit logic — call Dev-A's published functions. Dev-A merges before you integrate; the PM coordinates this. You may scaffold + write your failing tests against A's documented interface while you wait, but don't reimplement A.
|
||||||
|
- **Keep the org dispatch skeleton clean and additive.** Dev-C extends your `OrgAddKind` / `run_add` / `run_edit` with a Document arm and adds a `file` param to `run_edit`. Structure your dispatch so a fourth type slots in without a rewrite.
|
||||||
|
- Secrets via interactive prompts by default + `--*-stdin`. **`org get` must mask secrets without `--show`** — assert this in B4.
|
||||||
|
- Do not merge your branch — the PM merges (you merge after Dev-A, before Dev-C).
|
||||||
|
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Time: <iso8601>
|
||||||
|
Branch: feature/v0.8.1-dev-b-card-key-totp
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line>
|
||||||
|
Tests: <green | red (which) | N/A>
|
||||||
|
Notes: <≤3 sentences>
|
||||||
|
```
|
||||||
|
|
||||||
|
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-B` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-B` blocks — acknowledge and act.
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
|
||||||
|
|
||||||
|
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. Do not reimplement a Dev-A helper. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
|
||||||
|
|
||||||
|
## Escalate to PM when
|
||||||
|
|
||||||
|
A scope question outside the plan; a test you can't green after honest debugging; a discovered bug not in your plan; a needed change to Dev-A's interface; anything destructive; before REVIEW-READY.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run full validation from the worktree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p relicario-cli --test org_items
|
||||||
|
cargo test -p relicario-cli
|
||||||
|
cargo build -p relicario-cli
|
||||||
|
cargo clippy -p relicario-cli --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/v0.8.1-dev-b-card-key-totp
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-b-card-key-totp`, plan + Dev-A interface absorbed). Note that you depend on Dev-A and ask the PM to confirm Dev-A's interface is stable before you integrate. Start Task B1 (you can write failing tests against A's documented signatures immediately).
|
||||||
135
docs/superpowers/coordination/v0.8.1-dev-c-prompt.md
Normal file
135
docs/superpowers/coordination/v0.8.1-dev-c-prompt.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Dev C Kickoff Prompt — v0.8.1 Stream C (org Document + attachment storage)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Stream C for the v0.8.1 "org item-type parity" release.
|
||||||
|
|
||||||
|
You own **org Document support + collection-scoped attachment storage**: add `org_session` attachment methods (`attachment_path` / `save_attachment` / `load_attachment` / `remove_item_attachments`) + a default cap constant, add the Document arm to org `add`/`edit` (via `--file`, using Dev-A's `build_document`), make `purge` remove attachments, and update `docs/FORMATS.md`. You depend on **Dev-A** (`build_document`) and **Dev-B** (you extend B's org dispatch skeleton — B merges before you).
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with Dev-A, Dev-B, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git branch --list feature/v0.8.1-dev-c-document-attachments # ensure no collision; escalate if it exists
|
||||||
|
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-c -b feature/v0.8.1-dev-c-document-attachments
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-c
|
||||||
|
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-c
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-c`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-c` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
|
||||||
|
|
||||||
|
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — your `from` is always `"dev-c"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-c")`. After any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback** (relay tools not registered):
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-c"}'
|
||||||
|
```
|
||||||
|
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
|
||||||
|
|
||||||
|
## Relay polling cadence — MANDATORY (do NOT go head-down)
|
||||||
|
|
||||||
|
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task — and you have a live coordination dependency with Dev-D (see below), so an unread message is especially costly here.
|
||||||
|
|
||||||
|
**Call `read_messages(for="dev-c")` (run `list_pending(for="dev-c")` first if you want a cheap check) at ALL of these points:**
|
||||||
|
- Before dispatching EACH subagent — and again the moment it returns.
|
||||||
|
- Before EACH commit, and at the start + end of every task/step.
|
||||||
|
- Any time you've been heads-down for more than a few minutes.
|
||||||
|
|
||||||
|
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.4, org Document + attachment storage**)
|
||||||
|
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-C** section, Tasks C1–C4, task by task. Also read **Dev-A — Interfaces produced** (`build_document`) and the **Dev-B** section (the dispatch skeleton you extend).
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-c
|
||||||
|
```
|
||||||
|
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Tasks C1 (`org_session` attachment methods + `DEFAULT_ORG_ATTACHMENT_MAX_BYTES`), C2 (org `add document` + commit the attachment path), C3 (`purge` removes attachments + Document edit via `--file`), C4 (org Document integration tests + `docs/FORMATS.md`).
|
||||||
|
|
||||||
|
**Out of scope:** Dev-A's shared module, Dev-B's Card/Key/Totp, Dev-D's `relicario-server` hook. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- **You depend on Dev-A (`build_document`) and Dev-B (org dispatch skeleton).** B merges before you — rebase on B's `run_add`/`run_edit`. Don't reimplement A's builder or B's dispatch; extend them. You may scaffold + write failing tests against the documented interfaces while you wait.
|
||||||
|
- **C↔D attachment-path agreement (CRITICAL):** your storage layout is `attachments/<slug>/<item-id>/<att-id>.enc` — exactly **3 path segments** after `attachments/`. Dev-D's `classify_path` must authorize precisely this shape. **Confirm the exact path shape with Dev-D (via the PM) before you finalize C1**, and re-confirm if either side changes it. A mismatch means the hook rejects legitimate writes or leaves the authz gap open.
|
||||||
|
- **Cap = a default constant**, value taken from the personal-vault default in `crates/relicario-core/src/settings.rs` (`attachment_caps.per_attachment_max_bytes`). Verify the real value; cite the source line in a doc comment. Do not guess.
|
||||||
|
- When `run_edit` gains the `file` param (C3), update Dev-B's `run_edit` signature AND its `main.rs` dispatch together.
|
||||||
|
- Do not merge your branch — the PM merges (you merge last among the CLI streams, after Dev-B).
|
||||||
|
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-C
|
||||||
|
Time: <iso8601>
|
||||||
|
Branch: feature/v0.8.1-dev-c-document-attachments
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line>
|
||||||
|
Tests: <green | red (which) | N/A>
|
||||||
|
Notes: <≤3 sentences>
|
||||||
|
```
|
||||||
|
|
||||||
|
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-C` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-C` blocks — acknowledge and act. **Proactively coordinate the attachment path shape with Dev-D through the PM early.**
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
|
||||||
|
|
||||||
|
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. Reuse Dev-A's `build_document` + the existing `encrypt_attachment`/`decrypt_attachment` — don't reimplement. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
|
||||||
|
|
||||||
|
## Escalate to PM when
|
||||||
|
|
||||||
|
A scope question outside the plan; a test you can't green after honest debugging; any attachment-path-shape disagreement with Dev-D; a needed change to Dev-A's or Dev-B's interface; anything destructive; before REVIEW-READY.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run full validation from the worktree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p relicario-cli --test org_items
|
||||||
|
cargo test -p relicario-cli
|
||||||
|
cargo build -p relicario-cli
|
||||||
|
cargo clippy -p relicario-cli --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/v0.8.1-dev-c-document-attachments
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-c-document-attachments`, plan + Dev-A/Dev-B interfaces absorbed). **Immediately post a `## QUESTION TO PM` proposing the attachment path shape `attachments/<slug>/<item-id>/<att-id>.enc` and asking the PM to confirm it with Dev-D.** Then start Task C1 (you can build `org_session` attachment storage + its unit test immediately — it depends only on core, not on B).
|
||||||
133
docs/superpowers/coordination/v0.8.1-dev-d-prompt.md
Normal file
133
docs/superpowers/coordination/v0.8.1-dev-d-prompt.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Dev D Kickoff Prompt — v0.8.1 Stream D (server hook: grant-scope attachment paths)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Stream D for the v0.8.1 "org item-type parity" release.
|
||||||
|
|
||||||
|
You own the **`relicario-server` pre-receive hook change**: extend `classify_path` (`crates/relicario-server/src/lib.rs`) to recognize `attachments/<slug>/<item-id>/<att-id>.enc` and classify it as `PathClass::Item { collection: slug }` — converting attachment writes from `Unrestricted` to grant-scoped (closing a latent authz gap). Add server tests, bump the `relicario-server` version, and note the required server redeploy in `docs/SECURITY.md`. **You are fully independent of the CLI streams — start immediately.**
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with Dev-A, Dev-B, Dev-C. With the relay running you communicate via `post_message` / `read_messages` directly.
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git branch --list feature/v0.8.1-dev-d-server-hook # ensure no collision; escalate if it exists
|
||||||
|
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-d -b feature/v0.8.1-dev-d-server-hook
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-d
|
||||||
|
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-d
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-d`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-d` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
|
||||||
|
|
||||||
|
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — your `from` is always `"dev-d"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-d"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-d")`. After any status/question block: `post_message(from="dev-d", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback** (relay tools not registered):
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-d","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-d"}'
|
||||||
|
```
|
||||||
|
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
|
||||||
|
|
||||||
|
## Relay polling cadence — MANDATORY (do NOT go head-down)
|
||||||
|
|
||||||
|
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. You also have a live coordination dependency with Dev-C (the attachment path shape — see below), so an unread message can mean your hook and their storage disagree.
|
||||||
|
|
||||||
|
**Call `read_messages(for="dev-d")` (run `list_pending(for="dev-d")` first if you want a cheap check) at ALL of these points:**
|
||||||
|
- Before dispatching EACH subagent — and again the moment it returns.
|
||||||
|
- Before EACH commit, and at the start + end of every task/step.
|
||||||
|
- Any time you've been heads-down for more than a few minutes.
|
||||||
|
|
||||||
|
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered late costs rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.5, the hook change**)
|
||||||
|
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-D** section, Task D1, task by task
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario.v0.8.1-dev-d
|
||||||
|
```
|
||||||
|
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Task D1 — extend `classify_path` in `crates/relicario-server/src/lib.rs` for the `attachments/` branch; add classification tests to `crates/relicario-server/tests/org_hook.rs`; bump `relicario-server` version in `Cargo.toml`; note the grant-scoping change + required hook redeploy in `docs/SECURITY.md`.
|
||||||
|
|
||||||
|
**Out of scope:** all CLI work (Dev-A/B/C). The hook's `main.rs` authorization loop already handles `PathClass::Item { collection }` — you should NOT need to touch `main.rs`; if you think you do, escalate to PM first. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- **C↔D attachment-path agreement (CRITICAL):** you authorize the path shape `attachments/<slug>/<item-id>/<att-id>.enc` — exactly **3 path segments** after `attachments/`. This MUST match Dev-C's storage layout exactly. **Confirm the path shape with Dev-C (via the PM) before you finalize** the `classify_path` branch. A mismatch rejects legitimate writes or leaves the gap open.
|
||||||
|
- **Security-critical, do not relax the guards.** Mirror the existing `items/` branch defenses: exact segment count and a `.`-free slug guard (path-traversal defense). The `slug` you return as `collection` is what the existing grant + slug-existence check authorizes against.
|
||||||
|
- The existing `org_hook.rs` tests MUST stay green; add new ones, don't weaken old ones.
|
||||||
|
- Do not merge your branch — the PM merges (any order; you're independent).
|
||||||
|
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-D
|
||||||
|
Time: <iso8601>
|
||||||
|
Branch: feature/v0.8.1-dev-d-server-hook
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line>
|
||||||
|
Tests: <green | red (which) | N/A>
|
||||||
|
Notes: <≤3 sentences>
|
||||||
|
```
|
||||||
|
|
||||||
|
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-D` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-D` blocks — acknowledge and act. **Proactively confirm the attachment path shape with Dev-C through the PM early** — you'll likely finish before the CLI streams, so lock the contract before you go REVIEW-READY.
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
|
||||||
|
|
||||||
|
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Mirror the existing `items/` branch structure — don't invent a divergent pattern. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
|
||||||
|
|
||||||
|
## Escalate to PM when
|
||||||
|
|
||||||
|
A scope question outside the plan; a test you can't green after honest debugging; any attachment-path-shape disagreement with Dev-C; if you think you need to touch `main.rs`; anything destructive; before REVIEW-READY.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run full validation from the worktree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p relicario-server
|
||||||
|
cargo build -p relicario-server
|
||||||
|
cargo clippy -p relicario-server --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/v0.8.1-dev-d-server-hook
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-d-server-hook`, plan absorbed). **Immediately post a `## QUESTION TO PM` to confirm the attachment path shape `attachments/<slug>/<item-id>/<att-id>.enc` with Dev-C.** Then start Task D1 — you're independent, so go.
|
||||||
138
docs/superpowers/coordination/v0.8.1-pm-prompt.md
Normal file
138
docs/superpowers/coordination/v0.8.1-pm-prompt.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# PM Kickoff Prompt — v0.8.1 org item-type parity
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the **project manager** for the v0.8.1 "org item-type parity" release. 4 senior developers report to you, each working in their own terminal on a parallel feature branch + git worktree. The user runs all 5 terminals (manual kitty panes) and the relay routes messages between them.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Working directory: `/home/alee/Sources/relicario`
|
||||||
|
- Branch: stay on `main`. Do not check out feature branches.
|
||||||
|
- Today: 2026-06-20. Project rules in `CLAUDE.md` apply (note: Mexican-Spanish flourish in replies, Relicario capitalization, ask before destructive git ops).
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session (the relay server was not running when your session opened), use the Python shim:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"pm"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — the spec
|
||||||
|
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — the single plan; all four streams (Dev-A/B/C/D) live in this one file. Read the whole plan, especially the **Stream dependency graph** and the per-stream Interfaces blocks.
|
||||||
|
|
||||||
|
## The four streams + their dependency graph
|
||||||
|
|
||||||
|
- **Dev-A** — shared `commands/item_build.rs` foundation (secret resolution, builders, edit helpers) + personal `add`/`edit` refactor + personal `--*-stdin`. **Gates B and C.**
|
||||||
|
- **Dev-B** — org `add`/`edit` parity for Card/Key/Totp. Depends on A; establishes the org per-type dispatch skeleton in `commands/org.rs`.
|
||||||
|
- **Dev-C** — org Document + collection-scoped attachment storage. Depends on A (`build_document`) **and B** (extends B's org dispatch skeleton — **B merges before C**).
|
||||||
|
- **Dev-D** — `relicario-server` hook: grant-scope `attachments/<slug>/…` paths. **Fully independent — clear it to start immediately.**
|
||||||
|
|
||||||
|
**Merge order you must enforce:** D may merge anytime. **A merges first**, then **B**, then **C** (C rebases on B). Never let B or C merge before A.
|
||||||
|
|
||||||
|
## Your authority
|
||||||
|
|
||||||
|
- Approve or deny scope changes from devs
|
||||||
|
- Review each dev's branch and merge it to `main` (**you merge via git — see below**)
|
||||||
|
- Drive release-prep work that isn't a feature stream (CHANGELOG, version bumps to v0.8.1, STATUS/ROADMAP, the final integration sweep)
|
||||||
|
- Tag `v0.8.1` once everything is integrated **— only after explicit user approval**
|
||||||
|
|
||||||
|
## Your boundaries
|
||||||
|
|
||||||
|
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` are fine.
|
||||||
|
- Don't deviate from the spec without user approval.
|
||||||
|
- Don't merge a branch until the dev says `REVIEW-READY` and you've reviewed the diff.
|
||||||
|
- Don't tag without user approval.
|
||||||
|
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`).
|
||||||
|
|
||||||
|
## Judgment calls / coordination points worth flagging
|
||||||
|
|
||||||
|
The plan flagged these for your awareness:
|
||||||
|
|
||||||
|
- **Dev-A's `item_build` public interface is a CONTRACT.** Dev-B and Dev-C build against the signatures in the plan's "Dev-A — Interfaces produced" block. If Dev-A must change a signature, it must be announced on the relay *immediately* so B/C adjust.
|
||||||
|
- **C↔D attachment-path agreement.** Dev-C's storage layout (`attachments/<slug>/<item-id>/<att-id>.enc`, 3 path segments) MUST exactly match the shape Dev-D authorizes in `classify_path`. Get both to confirm the path shape with each other (via you) before either finalizes.
|
||||||
|
- **`run_edit` signature seam (B→C).** Dev-B writes `run_edit(dir, query, totp_qr)`; Dev-C's C3 adds a `file` param to that same function. Make sure C updates B's signature + the `main.rs` dispatch together when rebasing.
|
||||||
|
- **Cap constant.** Dev-C uses a default attachment cap constant that must match the personal-vault default in `crates/relicario-core/src/settings.rs` (cite the source line). Confirm the value is verified, not guessed.
|
||||||
|
- **Server redeploy.** Dev-D's hook change requires rebuilding the deployed pre-receive hook. The release notes/CHANGELOG must call this out.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
With the relay running, use `post_message` / `read_messages` directly — call `read_messages(for="pm")` before every action. If the relay tools aren't registered, fall back to the Python shim or ask the user to relay.
|
||||||
|
|
||||||
|
**Narrate to the user in plain prose between tool calls.** The PM terminal is the user's main window into the release. When a STATUS UPDATE lands, summarize it in a sentence or two before deciding. When you send a directive, state the rationale. When you dispatch a review subagent, say so. One or two sentences per beat — the user should read this terminal top-to-bottom and follow the release as a story.
|
||||||
|
|
||||||
|
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
|
||||||
|
|
||||||
|
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post via `post_message` and print it here. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## DIRECTIVE TO DEV-<letter>
|
||||||
|
Time: <iso8601>
|
||||||
|
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||||
|
Notes: <one paragraph max>
|
||||||
|
Next: <one concrete instruction or "continue plan">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Confirm your directives are actually seen.** Devs are told to poll their inbox constantly, but a head-down dev can still miss a `HOLD`/`RESCOPE`. After you post a `HOLD` or `RESCOPE`, watch that dev's next STATUS UPDATE for an explicit acknowledgement. If the dev keeps posting forward progress as if nothing changed (no ack, still dispatching subagents on the old premise), do NOT assume it landed — tell the user in plain prose to nudge that terminal directly ("Dev-C hasn't acked the HOLD — can you poke that pane?"). An unacknowledged HOLD is a blocker, not a sent-and-forget.
|
||||||
|
|
||||||
|
When the user asks "status?", give a rollup:
|
||||||
|
|
||||||
|
```
|
||||||
|
## RELEASE STATUS — v0.8.1
|
||||||
|
Devs: <per-dev one-line state>
|
||||||
|
PM: <what you're working on>
|
||||||
|
Blockers: <list, or "none">
|
||||||
|
Next milestone: <e.g., "Dev-A REVIEW-READY → unblocks B/C">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reviewing + merging branches (Gitea, not GitHub — `gh` is unusable here)
|
||||||
|
|
||||||
|
When a dev posts `Action: REVIEW-READY` with a branch name:
|
||||||
|
|
||||||
|
1. `git fetch origin`
|
||||||
|
2. `git log --oneline main..origin/<branch>` and `git diff main...origin/<branch>` — read the changes
|
||||||
|
3. Check the diff against the spec + that stream's plan tasks. Optionally dispatch a fresh subagent with `superpowers:requesting-code-review` for a deeper independent pass.
|
||||||
|
4. If green, **merge via git** (preserve history — no squash) and verify origin twice before pushing:
|
||||||
|
```bash
|
||||||
|
git checkout main && git pull --ff-only
|
||||||
|
git merge --no-ff origin/<branch> -m "merge: <branch> (v0.8.1 Dev-<letter>)"
|
||||||
|
git remote -v # verify origin is the Relicario remote, twice, before pushing
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
Then post `Action: MERGE-APPROVED` to that dev.
|
||||||
|
5. If red, post `Action: HOLD` with specific concerns.
|
||||||
|
|
||||||
|
Do not put unread/guessed SHAs in relay messages — only SHAs you've actually read from `git log`.
|
||||||
|
|
||||||
|
## Pre-tag checklist
|
||||||
|
|
||||||
|
Before tagging `v0.8.1`:
|
||||||
|
|
||||||
|
- [ ] Dev-A merged first; then Dev-B; then Dev-C; Dev-D merged (any order)
|
||||||
|
- [ ] Version bumped to 0.8.1 (relicario-core/cli/wasm) + relicario-server patch bump; CHANGELOG written; STATUS.md / ROADMAP.md updated
|
||||||
|
- [ ] `cargo test` (all crates) green on main + `cargo build -p relicario-wasm --target wasm32-unknown-unknown`
|
||||||
|
- [ ] `cd extension && npm run build:all` clean (extension untouched, but verify the workspace)
|
||||||
|
- [ ] Release notes call out the **coordinated relicario-server redeploy** (rebuild the pre-receive hook)
|
||||||
|
- [ ] User-driven smoke test of the merged result
|
||||||
|
- [ ] Explicit user approval to tag
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
1. `read_messages(for="pm")` to drain early inbox messages.
|
||||||
|
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the spec + plan, and list the dependency/merge order + the C↔D coordination point for the user.
|
||||||
|
3. Send opening directives: clear **Dev-A** and **Dev-D** to start immediately; tell **Dev-B** and **Dev-C** to create their worktrees + read + write failing tests against Dev-A's published interface, but hold integration until A merges (B before C).
|
||||||
|
4. Wait for acknowledgement STATUS UPDATEs from all four devs before clearing them to proceed.
|
||||||
File diff suppressed because it is too large
Load Diff
611
docs/superpowers/plans/2026-05-30-doc-structure-redesign.md
Normal file
611
docs/superpowers/plans/2026-05-30-doc-structure-redesign.md
Normal 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.
|
||||||
2660
docs/superpowers/plans/2026-05-30-extension-restructure.md
Normal file
2660
docs/superpowers/plans/2026-05-30-extension-restructure.md
Normal file
File diff suppressed because it is too large
Load Diff
6194
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
6194
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
File diff suppressed because it is too large
Load Diff
1216
docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md
Normal file
1216
docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md
Normal file
File diff suppressed because it is too large
Load Diff
397
docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md
Normal file
397
docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
# Key-File Second Factor — Core + CLI 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 (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the vault's 256-bit second factor pluggable — carryable by a plain key file as well as the stego image — at the core and CLI layers, chosen at `init` and recorded by a non-secret params hint.
|
||||||
|
|
||||||
|
**Architecture:** The second factor is already just 32 bytes (`image_secret`); stego is only the transport. We add a key-file armor format and a raw-secret unlock path in `relicario-core`, surface both over `relicario-wasm`, and branch the CLI's factor resolution on a new non-secret `second_factor` field in `params.json`. The Argon2id KDF and AEAD are byte-for-byte unchanged — only the *source* of the 32 bytes differs.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (relicario-core, relicario-cli, relicario-wasm), wasm-bindgen, clap.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Release target: v0.9.0.
|
||||||
|
- No new crypto primitive: reuse `derive_master_key` (`crates/relicario-core/src/crypto.rs:207`). The key-file path must derive the *same* master key as the stego path for the same 32-byte secret (equivalence is the security argument).
|
||||||
|
- `params.json` `second_factor` is **non-secret** and defaults to `image` when absent (every existing vault keeps working).
|
||||||
|
- The key file (`.relkey`) holds the secret in the clear — it is the "something you have," protected by needing the passphrase too. Same posture as the reference JPEG. Do not imply it is encrypted.
|
||||||
|
- Rust tests use fast Argon2id params (m=256, t=1, p=1).
|
||||||
|
- Capitalize "Relicario" in prose; the binary/command stays lowercase `relicario`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `crates/relicario-core/src/crypto.rs` — add `SecondFactor` enum + `second_factor` field on `KdfParams` (the struct serialized to `params.json`); confirm the 1:1 mapping first.
|
||||||
|
- `crates/relicario-core/src/keyfile.rs` *(new)* — `keyfile_encode` / `keyfile_decode` (armor format, `Zeroizing`).
|
||||||
|
- `crates/relicario-core/src/lib.rs` — `pub mod keyfile;` re-export.
|
||||||
|
- `crates/relicario-wasm/src/lib.rs` — `#[wasm_bindgen]` `keyfile_encode`, `keyfile_decode`, `unlock_with_secret`.
|
||||||
|
- `extension/src/wasm.d.ts` — declare the three (consumed by Plan 5).
|
||||||
|
- `crates/relicario-cli/src/session.rs` — `get_keyfile_path()`; branch `unlock_interactive` on the params hint.
|
||||||
|
- `crates/relicario-cli/src/commands/init.rs` (or wherever `init` lives — locate first) — `--key-file` path.
|
||||||
|
- `crates/relicario-cli/src/main.rs` — clap `--key-file` flag on `init` (and `RELICARIO_KEYFILE` doc in help).
|
||||||
|
- Tests: core unit tests in `keyfile.rs` + `crypto.rs`; `crates/relicario-wasm` equivalence test; `crates/relicario-cli/tests/keyfile_flows.rs` *(new)*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `SecondFactor` hint in `KdfParams`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/relicario-core/src/crypto.rs:157` (`KdfParams`)
|
||||||
|
- Test: `crates/relicario-core/src/crypto.rs` (`#[cfg(test)]`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `pub enum SecondFactor { Image, Keyfile }` (serde `rename_all = "lowercase"`); `KdfParams.second_factor: SecondFactor` with `#[serde(default)]` (default `Image`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm `KdfParams` ⟷ `params.json` is 1:1.** Grep where `params.json` is written/read in `crates/relicario-cli/src` and `backup.rs`; confirm it serializes `KdfParams`. If a wrapper struct is used instead, put `second_factor` there. Note the finding in a comment.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn params_default_second_factor_is_image_and_is_backcompat() {
|
||||||
|
// Old params.json (no second_factor) must deserialize as Image.
|
||||||
|
let old = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
|
||||||
|
let p: KdfParams = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(p.second_factor, SecondFactor::Image);
|
||||||
|
// New keyfile params round-trip.
|
||||||
|
let kf = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1,"second_factor":"keyfile"}"#;
|
||||||
|
let p2: KdfParams = serde_json::from_str(kf).unwrap();
|
||||||
|
assert_eq!(p2.second_factor, SecondFactor::Keyfile);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-core params_default_second_factor`
|
||||||
|
Expected: FAIL — `SecondFactor` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SecondFactor {
|
||||||
|
#[default]
|
||||||
|
Image,
|
||||||
|
Keyfile,
|
||||||
|
}
|
||||||
|
// in KdfParams:
|
||||||
|
#[serde(default)]
|
||||||
|
pub second_factor: SecondFactor,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-core params_default_second_factor` then `cargo test -p relicario-core` (no regressions; check `format_v2`/backup tests still pass since params.json gained an optional field).
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/relicario-core/src/crypto.rs
|
||||||
|
git commit -m "feat(core): SecondFactor hint on KdfParams (default image, back-compat)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Key-file armor (`keyfile_encode` / `keyfile_decode`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/relicario-core/src/keyfile.rs`
|
||||||
|
- Modify: `crates/relicario-core/src/lib.rs` (`pub mod keyfile;`)
|
||||||
|
- Test: `crates/relicario-core/src/keyfile.rs` (`#[cfg(test)]`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `pub fn keyfile_encode(secret: &[u8; 32]) -> Vec<u8>`; `pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>>`. Format: literal line `relicario-keyfile-v1\n`, then base64 (standard, no-pad-agnostic) of the 32 bytes, then `\n`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn round_trip() {
|
||||||
|
let secret = [9u8; 32];
|
||||||
|
let armored = keyfile_encode(&secret);
|
||||||
|
assert!(std::str::from_utf8(&armored).unwrap().starts_with("relicario-keyfile-v1\n"));
|
||||||
|
let back = keyfile_decode(&armored).unwrap();
|
||||||
|
assert_eq!(*back, secret);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_bad_header() {
|
||||||
|
assert!(keyfile_decode(b"not-a-keyfile\nAAAA\n").is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_length() {
|
||||||
|
assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-core keyfile`
|
||||||
|
Expected: FAIL — module/functions not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `keyfile.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! Key-file armor for the pluggable second factor. The file holds the raw
|
||||||
|
//! 32-byte secret (base64) behind a version header — it is the "something
|
||||||
|
//! you have", not an encrypted artifact (the passphrase is the other factor).
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
const HEADER: &str = "relicario-keyfile-v1";
|
||||||
|
|
||||||
|
pub fn keyfile_encode(secret: &[u8; 32]) -> Vec<u8> {
|
||||||
|
format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let text = std::str::from_utf8(bytes)
|
||||||
|
.map_err(|_| RelicarioError::InvalidFormat("key file is not UTF-8".into()))?;
|
||||||
|
let mut lines = text.lines();
|
||||||
|
if lines.next() != Some(HEADER) {
|
||||||
|
return Err(RelicarioError::InvalidFormat("bad key-file header".into()));
|
||||||
|
}
|
||||||
|
let b64 = lines.next().unwrap_or("").trim();
|
||||||
|
let decoded = STANDARD.decode(b64)
|
||||||
|
.map_err(|_| RelicarioError::InvalidFormat("key-file body not base64".into()))?;
|
||||||
|
let arr: [u8; 32] = decoded.as_slice().try_into()
|
||||||
|
.map_err(|_| RelicarioError::InvalidFormat("key-file secret must be 32 bytes".into()))?;
|
||||||
|
Ok(Zeroizing::new(arr))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the real `RelicarioError` variant — use the existing invalid-format/parse variant; `grep RelicarioError crates/relicario-core/src/error.rs` first.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-core keyfile`
|
||||||
|
Expected: PASS (all three).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/relicario-core/src/keyfile.rs crates/relicario-core/src/lib.rs
|
||||||
|
git commit -m "feat(core): key-file armor (relicario-keyfile-v1) encode/decode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: WASM bindings + master-key equivalence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/relicario-wasm/src/lib.rs`
|
||||||
|
- Modify: `extension/src/wasm.d.ts`
|
||||||
|
- Test: `crates/relicario-wasm/src/lib.rs` (`#[cfg(test)]`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (consumed by Plan 5): `keyfile_encode(secret: Uint8Array): Uint8Array`; `keyfile_decode(bytes: Uint8Array): Uint8Array`; `unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (equivalence: same secret ⇒ same master key, proven by cross-decrypt)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn unlock_with_secret_matches_unlock_from_jpeg() {
|
||||||
|
let secret = [3u8; 32];
|
||||||
|
let salt = [1u8; 32];
|
||||||
|
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
|
||||||
|
let jpeg = relicario_core::imgsecret::embed(&make_test_jpeg(), &secret).unwrap();
|
||||||
|
|
||||||
|
let h_img = unlock(/*passphrase*/ "pw", &jpeg, &salt, params).unwrap();
|
||||||
|
let h_key = unlock_with_secret("pw", &secret, &salt, params).unwrap();
|
||||||
|
|
||||||
|
// Same key ⇒ a blob encrypted under one handle decrypts under the other.
|
||||||
|
let ct = item_encrypt(&h_img, r#"{"id":"a","core":{"type":"SecureNote","body":"z"}}"#).unwrap();
|
||||||
|
let pt = item_decrypt(&h_key, &ct).unwrap();
|
||||||
|
assert!(format!("{pt:?}").contains("SecureNote"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-wasm unlock_with_secret`
|
||||||
|
Expected: FAIL — `unlock_with_secret` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** (mirror `unlock` at `lib.rs:49`, skipping `imgsecret::extract`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn unlock_with_secret(
|
||||||
|
passphrase: &str,
|
||||||
|
secret: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
params_json: &str,
|
||||||
|
) -> Result<SessionHandle, JsError> {
|
||||||
|
let params: KdfParams = serde_json::from_str(params_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("params: {e}")))?;
|
||||||
|
let secret_arr: &[u8; 32] = secret.try_into()
|
||||||
|
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
|
||||||
|
let salt_arr: &[u8; 32] = salt.try_into()
|
||||||
|
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||||
|
let master_key = derive_master_key(passphrase.as_bytes(), secret_arr, salt_arr, ¶ms)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let handle = session::insert(master_key, Zeroizing::new(*secret_arr));
|
||||||
|
Ok(SessionHandle(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn keyfile_encode(secret: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||||
|
let arr: &[u8; 32] = secret.try_into().map_err(|_| JsError::new("secret must be 32 bytes"))?;
|
||||||
|
Ok(relicario_core::keyfile::keyfile_encode(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn keyfile_decode(bytes: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||||
|
let s = relicario_core::keyfile::keyfile_decode(bytes).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(s.to_vec())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-wasm`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Declare in `wasm.d.ts` + build**
|
||||||
|
|
||||||
|
Add to `extension/src/wasm.d.ts`:
|
||||||
|
```ts
|
||||||
|
export function keyfile_encode(secret: Uint8Array): Uint8Array;
|
||||||
|
export function keyfile_decode(bytes: Uint8Array): Uint8Array;
|
||||||
|
export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle;
|
||||||
|
```
|
||||||
|
Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` + the project wasm-pack step.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts
|
||||||
|
git commit -m "feat(wasm): unlock_with_secret + keyfile encode/decode bindings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: CLI unlock branches on the params hint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/relicario-cli/src/session.rs` (`get_keyfile_path`, `unlock_interactive`)
|
||||||
|
- Test: `crates/relicario-cli/tests/keyfile_flows.rs` *(new)*
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `keyfile_decode`, `derive_master_key`, `KdfParams.second_factor`.
|
||||||
|
- Produces: `pub fn get_keyfile_path() -> Result<PathBuf>` (mirrors `get_image_path` at `session.rs:165`: `RELICARIO_KEYFILE` env → `<vault_root>/vault.relkey` convention → interactive prompt). `unlock_interactive` reads `second_factor` from params and resolves the image OR the key file accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing integration test**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn init_keyfile_then_unlock_keyfile_round_trips() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// init with a key file (Task 5 wires the flag; here drive it once that lands)
|
||||||
|
relicario(&dir).args(["init", "--key-file", "vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success();
|
||||||
|
// unlock + add + get using the key file
|
||||||
|
relicario(&dir)
|
||||||
|
.args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"])
|
||||||
|
.env("RELICARIO_PASSPHRASE","correct horse")
|
||||||
|
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
|
||||||
|
.assert().success();
|
||||||
|
relicario(&dir).args(["get","gh","--show"])
|
||||||
|
.env("RELICARIO_PASSPHRASE","correct horse")
|
||||||
|
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
|
||||||
|
.assert().success().stdout(predicates::str::contains("u"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-cli --test keyfile_flows`
|
||||||
|
Expected: FAIL — `--key-file` unknown / `RELICARIO_KEYFILE` ignored.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `get_keyfile_path` + the unlock branch**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn get_keyfile_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(path) = std::env::var("RELICARIO_KEYFILE") { return Ok(PathBuf::from(path)); }
|
||||||
|
if let Some(root) = find_vault_root() { // mirror get_image_path's convention block
|
||||||
|
let default = root.join("vault.relkey");
|
||||||
|
if default.exists() { return Ok(default); }
|
||||||
|
}
|
||||||
|
let trimmed = prompt("key file path: ")?;
|
||||||
|
if trimmed.is_empty() { bail!("no key file path provided"); }
|
||||||
|
Ok(PathBuf::from(trimmed))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `unlock_interactive` (`session.rs:33`): after loading `params`, branch — `SecondFactor::Image` keeps today's `get_image_path` + `imgsecret::extract`; `SecondFactor::Keyfile` does `keyfile_decode(fs::read(get_keyfile_path()?)?)` → `derive_master_key(passphrase, &secret, &salt, ¶ms)`. Map a missing/garbled key file to a clear `invalid_key_file` error distinct from the wrong-passphrase AEAD failure.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes** (after Task 5 wires `init --key-file`; if executing in order, mark this test `#[ignore]` until Task 5, then un-ignore)
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-cli --test keyfile_flows`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/relicario-cli/src/session.rs crates/relicario-cli/tests/keyfile_flows.rs
|
||||||
|
git commit -m "feat(cli): unlock resolves second factor from params hint (image|keyfile)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: CLI `init --key-file`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the `init` command handler (locate: `grep -rn 'Init\|fn.*init' crates/relicario-cli/src/`), `crates/relicario-cli/src/main.rs` (clap flag)
|
||||||
|
- Test: `crates/relicario-cli/tests/keyfile_flows.rs` (un-ignore Task 4's test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `keyfile_encode`; `KdfParams { second_factor: Keyfile, .. }`.
|
||||||
|
- Produces: `relicario init --key-file <path>` — generates the 32-byte secret with `OsRng`, writes `keyfile_encode(secret)` to `<path>`, derives the master key from passphrase+secret, and writes `params.json` with `second_factor: "keyfile"`. The existing `--image`/`--output` path stays the default and writes `second_factor: "image"`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** — un-ignore `init_keyfile_then_unlock_keyfile_round_trips` and add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn init_keyfile_writes_relkey_and_keyfile_params() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
relicario(&dir).args(["init","--key-file","vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success();
|
||||||
|
assert!(dir.path().join("vault.relkey").exists());
|
||||||
|
let params = std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap();
|
||||||
|
assert!(params.contains("\"second_factor\":\"keyfile\""));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-cli --test keyfile_flows init_keyfile_writes`
|
||||||
|
Expected: FAIL — `--key-file` not a known arg.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — add `#[arg(long, conflicts_with = "image")] key_file: Option<PathBuf>` to the init args in `main.rs`; in the init handler, when `key_file` is set: `let secret: [u8;32] = OsRng.gen();` → `fs::write(path, keyfile_encode(&secret))` → derive master key from `&secret` → set `KdfParams { second_factor: SecondFactor::Keyfile, ..default }` before writing `params.json`. Reuse the existing init crypto/write path otherwise.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-cli --test keyfile_flows`
|
||||||
|
Expected: PASS (both tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full suite + commit**
|
||||||
|
|
||||||
|
Run: `cargo test` (workspace) — confirm no personal-vault init/unlock regressions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/ crates/relicario-cli/tests/keyfile_flows.rs
|
||||||
|
git commit -m "feat(cli): init --key-file generates a .relkey second factor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hand-off contract (consumed by Plan 5: extension + positioning)
|
||||||
|
|
||||||
|
- Core: `keyfile_encode(&[u8;32]) -> Vec<u8>`, `keyfile_decode(&[u8]) -> Result<Zeroizing<[u8;32]>>`, `SecondFactor { Image, Keyfile }` on `KdfParams` (absent ⇒ `Image`).
|
||||||
|
- WASM / `wasm.d.ts`: `keyfile_encode(Uint8Array): Uint8Array`, `keyfile_decode(Uint8Array): Uint8Array`, `unlock_with_secret(passphrase, secret, salt, params_json): SessionHandle`.
|
||||||
|
- Armor: `relicario-keyfile-v1\n` + base64(32 bytes) + `\n`; extension `.relkey`.
|
||||||
|
- `params.json` carries `"second_factor": "image" | "keyfile"`. Plan 5's setup wizard writes the hint and its unlock reads it to choose the image picker vs the key-file picker, calling `unlock_with_secret` for the key-file path.
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Key-File Second Factor — Extension + Positioning 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 (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let users choose a key file instead of the stego image at setup, unlock with it in the browser, and re-lead the project's positioning on the durable thesis.
|
||||||
|
|
||||||
|
**Architecture:** The setup wizard gains a second-factor container choice; in key-file mode the SW `create_vault` generates the 32-byte secret, returns the `.relkey` armor for download, stores `keyfileBase64` (exactly as `imageBase64` is stored today), and writes `params.json` `second_factor: "keyfile"`. The SW `unlock` handler branches on that hint — image path unchanged, key-file path calls `unlock_with_secret`. Then the docs lead with the thesis and frame stego as an option.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript (extension setup + SW), vitest; Markdown docs. Consumes Plan 4's core/WASM/params contract.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Release target: v0.9.0.
|
||||||
|
- Consume Plan 4 verbatim: WASM `keyfile_encode`/`keyfile_decode`/`unlock_with_secret`; `params.json` `second_factor: "image"|"keyfile"` (absent ⇒ image).
|
||||||
|
- Binary crosses `chrome.runtime.sendMessage` base64-enveloped (`shared/message-binary.ts`) — ArrayBuffers are dropped otherwise.
|
||||||
|
- `keyfileBase64` is the second factor in the clear in `chrome.storage.local`, exactly the posture of today's `imageBase64`. Document it as equivalent, not weaker.
|
||||||
|
- Existing image vaults must be unaffected (the `second_factor` default is `image`).
|
||||||
|
- Keep `manifest.json`/`manifest.firefox.json` in sync. Capitalize "Relicario" in prose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `extension/src/setup/setup-steps.ts` — `WizardState.secondFactor`; step-3 container-choice UI; key-file download flow.
|
||||||
|
- `extension/src/service-worker/router/popup-only.ts` — `create_vault` key-file branch (`:636`); `unlock` branch on the params hint (`:40-51`); store `keyfileBase64` in `save_setup` (`:144`).
|
||||||
|
- `extension/src/service-worker/vault.ts` — `create_vault` orchestration: key-file mode generates the secret + returns `.relkey`.
|
||||||
|
- `extension/src/shared/messages.ts` — `create_vault` request gains `secondFactor`; response carries optional `relkeyBytes`.
|
||||||
|
- Docs: `README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md`.
|
||||||
|
- Tests: `extension/src/setup/__tests__/setup-steps.test.ts`, `extension/src/service-worker/__tests__/keyfile-unlock.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Wizard container choice (Image | Key File)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/setup/setup-steps.ts` (`WizardState` ~`:47`, step-3 new-vault render ~`:398`)
|
||||||
|
- Test: `extension/src/setup/__tests__/setup-steps.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `WizardState.secondFactor: 'image' | 'keyfile'` (default `'image'`); step-3 shows a radio/segmented control; selecting "Key File" hides the carrier-image drop and shows a "a 32-byte key file will be generated for you to save" note.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderStep3New, defaultWizardState } from '../setup-steps';
|
||||||
|
test('step 3 offers a second-factor choice; key-file hides the carrier drop', () => {
|
||||||
|
const state = { ...defaultWizardState(), secondFactor: 'keyfile' as const };
|
||||||
|
const html = renderStep3New(state);
|
||||||
|
expect(html).toContain('Key File');
|
||||||
|
expect(html).not.toContain('A 256-bit secret will be steganographically embedded');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "second-factor choice"`
|
||||||
|
Expected: FAIL — no `secondFactor` field / choice UI.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — add `secondFactor: 'image'|'keyfile'` to `WizardState` (default `'image'` in `defaultWizardState`, ~`:63`); add a segmented control to the step-3 new-vault markup (`:398-406`); when `'keyfile'`, replace the carrier drop with the key-file note.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "second-factor choice"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/setup/setup-steps.ts extension/src/setup/__tests__/setup-steps.test.ts
|
||||||
|
git commit -m "feat(ext/setup): second-factor container choice (image | key file)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: SW `create_vault` key-file branch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/service-worker/vault.ts` (`create_vault` orchestration), `router/popup-only.ts:636`, `extension/src/shared/messages.ts`
|
||||||
|
- Test: `extension/src/service-worker/__tests__/keyfile-unlock.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `wasm.keyfile_encode`, `wasm.unlock_with_secret` (Plan 4).
|
||||||
|
- Produces: `create_vault` request gains `secondFactor: 'image'|'keyfile'`; in key-file mode the SW generates a 32-byte secret (`crypto.getRandomValues`), derives via `unlock_with_secret`, writes `params.json` with `second_factor: "keyfile"`, stores `keyfileBase64`, and returns `{ ok, data: { relkeyBytes } }` (base64-enveloped) for download. Image mode is unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('create_vault keyfile mode stores keyfileBase64 and returns relkey bytes', async () => {
|
||||||
|
const set = vi.spyOn(chrome.storage.local, 'set').mockResolvedValue();
|
||||||
|
const resp = await handleCreateVault({ secondFactor: 'keyfile', config: fakeConfig } as any, fakeState);
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
expect(resp.data.relkeyBytes).toBeDefined();
|
||||||
|
const stored = JSON.stringify(set.mock.calls);
|
||||||
|
expect(stored).toContain('keyfileBase64');
|
||||||
|
expect(stored).not.toContain('imageBase64');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "create_vault keyfile"`
|
||||||
|
Expected: FAIL — `create_vault` ignores `secondFactor`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — in the `create_vault` path: if `secondFactor === 'keyfile'`, `const secret = crypto.getRandomValues(new Uint8Array(32))`; `const handle = w.unlock_with_secret(passphrase, secret, salt, paramsJsonWithKeyfileHint)`; encrypt+push empty manifest/settings (reuse the image path's tail); `storageUpdate.keyfileBase64 = base64(keyfile_encode(secret))`; set `params.json` `second_factor: "keyfile"`; return `{ relkeyBytes: keyfile_encode(secret) }` base64-enveloped. Add `secondFactor` to the `create_vault` request type and `relkeyBytes?` to its response in `messages.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "create_vault keyfile"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts
|
||||||
|
git commit -m "feat(ext/sw): create_vault key-file mode (generate secret, store keyfileBase64)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Wizard key-file download flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/setup/setup-steps.ts` (finish/device step)
|
||||||
|
- Test: `extension/src/setup/__tests__/setup-steps.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `create_vault` response `{ relkeyBytes }`.
|
||||||
|
- Produces: after a key-file `create_vault`, the wizard triggers a download of `vault.relkey` (the returned bytes) and shows "save this key file — it is your second factor; you cannot unlock without it."
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('keyfile setup triggers a .relkey download from create_vault response', async () => {
|
||||||
|
const dl = vi.fn();
|
||||||
|
vi.stubGlobal('URL', { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} });
|
||||||
|
await finishKeyfileSetup({ relkeyBytes: new Uint8Array([1,2,3]) }, dl); // dl = injected download trigger
|
||||||
|
expect(dl).toHaveBeenCalledWith('vault.relkey', expect.any(Blob));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "relkey download"`
|
||||||
|
Expected: FAIL — no download path.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — when `secondFactor === 'keyfile'`, the finish step sends `create_vault { secondFactor: 'keyfile' }`, decodes `relkeyBytes` (base64 envelope), and triggers a `vault.relkey` download (anchor + object URL); show the "save this key file" copy.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "relkey download"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/setup/setup-steps.ts extension/src/setup/__tests__/setup-steps.test.ts
|
||||||
|
git commit -m "feat(ext/setup): download the generated .relkey at finish"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: SW `unlock` branches on the params hint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/service-worker/router/popup-only.ts:40-51`
|
||||||
|
- Test: `extension/src/service-worker/__tests__/keyfile-unlock.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `params.json` `second_factor`; `keyfileBase64`; `wasm.keyfile_decode`, `wasm.unlock_with_secret`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('unlock uses unlock_with_secret when params say keyfile', async () => {
|
||||||
|
chrome.storage.local.get = vi.fn().mockResolvedValue({ vaultConfig: fakeCfg, keyfileBase64: KF_B64 });
|
||||||
|
const w = { keyfile_decode: vi.fn(() => new Uint8Array(32)), unlock_with_secret: vi.fn(() => fakeHandle), unlock: vi.fn() };
|
||||||
|
await handleUnlock({ type: 'unlock', passphrase: 'pw' }, stateWith(w, /*params second_factor=keyfile*/));
|
||||||
|
expect(w.unlock_with_secret).toHaveBeenCalled();
|
||||||
|
expect(w.unlock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "unlock uses unlock_with_secret"`
|
||||||
|
Expected: FAIL — unlock always calls `w.unlock`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — parse `meta.paramsJson`; if `second_factor === 'keyfile'`: load `keyfileBase64`, `const secret = w.keyfile_decode(base64ToUint8Array(keyfileBase64))`, `w.unlock_with_secret(passphrase, secret, salt, paramsJson)`. Else the existing image path. Map a missing/garbled key file to `invalid_key_file`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts`
|
||||||
|
Expected: PASS (image-mode unlock test still green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/service-worker/router/popup-only.ts
|
||||||
|
git commit -m "feat(ext/sw): unlock resolves second factor from params hint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Attach-mode key-file picker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/setup/setup-steps.ts` (step-3 attach branch ~`:353-362`), `router/popup-only.ts` (`attach_vault`)
|
||||||
|
- Test: `extension/src/setup/__tests__/setup-steps.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: when attaching to a vault whose probe/params indicate `second_factor: "keyfile"`, the attach step prompts for the `.relkey` file (mirroring the reference-image `<input type="file">` at `:357-360`) instead of the JPEG; the chosen bytes are stored as `keyfileBase64`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('attach step asks for a key file when the vault uses keyfile', () => {
|
||||||
|
const html = renderStep3Attach({ ...defaultWizardState(), attachSecondFactor: 'keyfile' } as any);
|
||||||
|
expect(html).toContain('key file (.relkey)');
|
||||||
|
expect(html).not.toContain('reference image (JPEG)');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "attach step asks for a key file"`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — detect the vault's `second_factor` during the connection probe; in the attach step render a `.relkey` file input when keyfile; `attach_vault` stores `keyfileBase64` and verifies by attempting `unlock_with_secret`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full suite + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/setup/setup-steps.ts extension/src/service-worker/router/popup-only.ts
|
||||||
|
git commit -m "feat(ext/setup): attach via key file when the vault uses one"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Positioning pivot — docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md`
|
||||||
|
|
||||||
|
No automated test — this is prose. The "verification" is the consistency checklist in Step 4.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Re-lead `README.md`.** Open with the thesis: "two independent secrets into the KDF, self-hosted, a server that holds only opaque ciphertext, and a git-backed audit log." Move the steganography explanation below that, framed as one **option** for the second factor (with the key file as the plain alternative); keep the dead-drop story as flavor, not the headline. Update the "How it works" diagram caption to say "passphrase + second factor (reference image or key file)".
|
||||||
|
|
||||||
|
- [ ] **Step 2: `docs/CRYPTO.md` + `docs/FORMATS.md`.** CRYPTO: add the pluggable-transport framing — "the second factor is 32 bytes; the reference image, the key file, and the recovery QR are interchangeable containers for it; the Argon2id input and master-key derivation are identical regardless of container." FORMATS: document the `.relkey` armor (`relicario-keyfile-v1` + base64(32 bytes)) and the `params.json` `second_factor` field (`"image"|"keyfile"`, absent ⇒ image), citing `crates/relicario-core/src/keyfile.rs` and `crypto.rs` `KdfParams`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `DESIGN.md` secrets-map + `docs/SECURITY.md`.** DESIGN: add the key file to the secrets map alongside the reference image. SECURITY: state that `.relkey` / `keyfileBase64` is the second factor in the clear — the same posture as the reference JPEG / `imageBase64` — protected by the passphrase being required too; it is NOT an encrypted artifact.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Consistency check + commit.** Verify: README leads with the thesis (not stego); every place that said "passphrase + reference image" now reads "passphrase + second factor (image or key file)"; FORMATS cites the source files; no doc claims the key file is encrypted. Per CLAUDE.md living-docs discipline, confirm scope headers/Next-footers still hold.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md
|
||||||
|
git commit -m "docs: re-lead positioning on the two-factor-KDF thesis; document the key-file second factor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The attach-mode probe must learn the vault's `second_factor` before the user supplies the factor — read `params.json` during the existing connection-test/probe step (`setup/probe.ts`).
|
||||||
|
- Security-review gate (per spec): after this plan, run `/security-review` on the key-file path — equivalence to the stego path, armor parsing, and the in-the-clear-storage documentation.
|
||||||
467
docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md
Normal file
467
docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
# Org Foundation (SW + WASM) 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 (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Give the extension service worker the data layer to switch into an org vault, unwrap the org master key into a Zeroizing WASM handle, and serve a grant-filtered org manifest — no UI.
|
||||||
|
|
||||||
|
**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`) and the existing `item_decrypt`/`manifest_decrypt` AEAD (org items share the personal `.enc` format, org key used directly). The only new WASM function is `org_unwrap_key`. In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (relicario-core/wasm), wasm-bindgen, TypeScript (extension service worker), vitest + happy-dom.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Release target: v0.9.0.
|
||||||
|
- Org master key NEVER written to `localStorage`/`IndexedDB`/any persistent store — it lives only in a Zeroizing WASM session (`relicario-core` `Drop` zeroizes on `.free()`).
|
||||||
|
- Master key never crosses the WASM boundary; JS holds only the opaque `SessionHandle` (`u32`).
|
||||||
|
- Every new SW message needs all three: `PopupMessage` union entry + `POPUP_ONLY_TYPES` entry + handler arm (`extension/src/shared/messages.ts`) — a message in the union but not the set is silently rejected.
|
||||||
|
- Org crypto bypasses Argon2id (X25519 key-wrap), so the fast-Argon2id test-params convention does not apply to org tests; standard params apply only where shared fixtures touch the personal path.
|
||||||
|
- Capitalize "Relicario" in prose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key`. (Reuses `session::insert`; reuses existing `manifest_decrypt`/`item_decrypt`/`item_encrypt`/`manifest_encrypt` on the returned handle.)
|
||||||
|
- `crates/relicario-core/src/manifest.rs` — ensure a `ManifestEntry` carries an optional `collection: Option<String>` so the org manifest round-trips through the existing manifest (de)serialization. (Verify first; only add if absent.)
|
||||||
|
- `extension/src/wasm.d.ts` — declare `org_unwrap_key`.
|
||||||
|
- `extension/src/service-worker/session.ts` — replace single-handle model with a context map (personal + orgs); zero ALL on lock/expiry.
|
||||||
|
- `extension/src/service-worker/org-config.ts` *(new)* — `orgConfigs` read/write over `chrome.storage.local`.
|
||||||
|
- `extension/src/service-worker/org-vault.ts` *(new)* — org read ops: load `members.json`/`collections.json`, match this device's member, unwrap key, fetch+decrypt+grant-filter the org manifest, get one item.
|
||||||
|
- `extension/src/service-worker/router/org-handlers.ts` *(new)* — handler arms for the org messages (keeps `popup-only.ts` from bloating).
|
||||||
|
- `extension/src/service-worker/router/popup-only.ts` — dispatch the new org message types into `org-handlers.ts`.
|
||||||
|
- `extension/src/shared/messages.ts` — org message request/response shapes + `POPUP_ONLY_TYPES` entries.
|
||||||
|
- `extension/src/shared/types.ts` — `OrgConfig`, `OrgConfigSummary`, `Collection`, `OrgMember`, manifest `collection?`.
|
||||||
|
- Tests: `crates/relicario-wasm` inline test for `org_unwrap_key`; `extension/src/service-worker/__tests__/org-session.test.ts`, `org-config.test.ts`, `org-vault.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: WASM `org_unwrap_key`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/relicario-wasm/src/lib.rs` (add after the personal `unlock`, ~`:49-65`)
|
||||||
|
- Modify: `extension/src/wasm.d.ts`
|
||||||
|
- Test: `crates/relicario-wasm/src/lib.rs` (`#[cfg(test)]` module) or `crates/relicario-core/src/org.rs` test if wasm-bindgen blocks a unit test
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `relicario_core::org::unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8;32]>) -> Result<Zeroizing<[u8;32]>>` (`crates/relicario-core/src/org.rs:299`); `session::insert(master_key, image_secret) -> u32` (`crates/relicario-wasm/src/session.rs`).
|
||||||
|
- Produces: `org_unwrap_key(keys_blob: &[u8], device_private_key_base64: &str) -> Result<SessionHandle, JsError>`. The returned handle is an ordinary `SessionHandle` — callers use the existing `item_decrypt`/`item_encrypt`/`manifest_decrypt`/`manifest_encrypt` with it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm the device-key form.** Read how `device_private_key` is produced — `crates/relicario-wasm/src/lib.rs` `register_device`/`generate_device_keypair` and `crates/relicario-core/src/device.rs`. Determine whether `private_key_base64` is the raw 32-byte ed25519 seed or an OpenSSH blob, and write `org_unwrap_key` to decode it to the 32-byte seed `Zeroizing<[u8;32]>` that `unwrap_org_key` expects. Note the finding in a code comment.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod org_tests {
|
||||||
|
use super::*;
|
||||||
|
use relicario_core::org::wrap_org_key;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() {
|
||||||
|
// Generate a device keypair, wrap a known org key to it, unwrap via the wasm path,
|
||||||
|
// then encrypt+decrypt an item through the returned handle and assert round-trip.
|
||||||
|
let org_key = Zeroizing::new([7u8; 32]);
|
||||||
|
let (pub_openssh, priv_b64) = test_device_keypair(); // helper mirrors generate_device_keypair output
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap();
|
||||||
|
|
||||||
|
let handle = org_unwrap_key(&wrapped, &priv_b64).unwrap();
|
||||||
|
let ct = item_encrypt(&handle, r#"{"id":"a1","core":{"type":"SecureNote","body":"x"}}"#).unwrap();
|
||||||
|
let pt = item_decrypt(&handle, &ct).unwrap(); // JsValue → assert it deserializes
|
||||||
|
assert!(format!("{pt:?}").contains("SecureNote"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-wasm org_unwrap_key`
|
||||||
|
Expected: FAIL — `cannot find function org_unwrap_key`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `org_unwrap_key`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Unwrap a member's ECIES-wrapped org master key into a session handle.
|
||||||
|
/// The org key is held in the same Zeroizing WASM session registry as the
|
||||||
|
/// personal master key; org items share the personal `.enc` AEAD format, so
|
||||||
|
/// the returned handle works with item_decrypt/manifest_decrypt unchanged.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn org_unwrap_key(
|
||||||
|
keys_blob: &[u8],
|
||||||
|
device_private_key_base64: &str,
|
||||||
|
) -> Result<SessionHandle, JsError> {
|
||||||
|
let seed = decode_device_seed(device_private_key_base64) // per Step 1 finding
|
||||||
|
.map_err(|e| JsError::new(&format!("bad device key: {e}")))?;
|
||||||
|
let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed)
|
||||||
|
.map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?;
|
||||||
|
// image_secret slot unused for org; fill with zeroized placeholder.
|
||||||
|
let handle = session::insert(org_key, Zeroizing::new([0u8; 32]));
|
||||||
|
Ok(SessionHandle(handle))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-wasm org_unwrap_key`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Declare in `wasm.d.ts` + build**
|
||||||
|
|
||||||
|
Add to `extension/src/wasm.d.ts`: `export function org_unwrap_key(keys_blob: Uint8Array, device_private_key_base64: string): SessionHandle;`
|
||||||
|
Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` then the project's wasm-pack step (see root `CLAUDE.md`).
|
||||||
|
Expected: builds clean.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts
|
||||||
|
git commit -m "feat(wasm): org_unwrap_key — ECIES unwrap into a session handle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Org manifest `collection` field round-trips
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/relicario-core/src/manifest.rs`
|
||||||
|
- Modify: `extension/src/shared/types.ts`
|
||||||
|
- Test: `crates/relicario-core/tests/format_v2.rs` (or the manifest test module)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ManifestEntry.collection: Option<String>` (serde `skip_serializing_if = "Option::is_none"`), mirrored in TS as `collection?: string`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check current state.** Grep `crates/relicario-core/src/manifest.rs` for `collection`. If the org manifest already round-trips (org CLI works, so it likely uses a dedicated type or already has the field), this task is a no-op verification — confirm with a test and skip to commit. If `ManifestEntry` lacks `collection`, proceed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn manifest_entry_round_trips_collection_slug() {
|
||||||
|
let json = r#"{"id":"a1","title":"db","collection":"prod-infra","modified":1}"#;
|
||||||
|
let entry: ManifestEntry = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(entry.collection.as_deref(), Some("prod-infra"));
|
||||||
|
let back = serde_json::to_string(&entry).unwrap();
|
||||||
|
assert!(back.contains("prod-infra"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-core manifest_entry_round_trips_collection_slug`
|
||||||
|
Expected: FAIL (unknown field or missing accessor) — or PASS immediately if the field already exists (then this task is verification-only).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the field if absent**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub collection: Option<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test -p relicario-core manifest`
|
||||||
|
Expected: PASS, no other manifest test regressed.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Mirror in TS + commit**
|
||||||
|
|
||||||
|
Add `collection?: string;` to the `ManifestEntry` interface in `extension/src/shared/types.ts`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/relicario-core/src/manifest.rs extension/src/shared/types.ts
|
||||||
|
git commit -m "feat(core): ManifestEntry carries optional collection slug"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Multi-context SW session
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/service-worker/session.ts`
|
||||||
|
- Modify: `extension/src/service-worker/index.ts` (timer-expiry zero-all), `router/popup-only.ts` (the `lock` handler)
|
||||||
|
- Test: `extension/src/service-worker/__tests__/org-session.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `setPersonal(h)`, `getPersonal()`, `setOrg(orgId, h)`, `getOrg(orgId)`, `setContext('personal'|orgId)`, `currentContext()`, `requireCurrentHandle()` (throws `vault_locked`), `clearAll()` (frees every handle). Keeps `getCurrent()`/`requireCurrent()`/`clearCurrent()` as thin wrappers over the personal handle so existing personal callers compile unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as session from '../session';
|
||||||
|
test('clearAll frees personal and every org handle', () => {
|
||||||
|
const free = vi.fn();
|
||||||
|
const mk = (id: number) => ({ value: id, free } as unknown as SessionHandle);
|
||||||
|
session.setPersonal(mk(1));
|
||||||
|
session.setOrg('org-a', mk(2));
|
||||||
|
session.setOrg('org-b', mk(3));
|
||||||
|
session.clearAll();
|
||||||
|
expect(free).toHaveBeenCalledTimes(3);
|
||||||
|
expect(session.getPersonal()).toBeNull();
|
||||||
|
expect(session.getOrg('org-a')).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-session.test.ts`
|
||||||
|
Expected: FAIL — `setPersonal`/`setOrg`/`clearAll` not exported.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the context model**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||||
|
|
||||||
|
let personal: SessionHandle | null = null;
|
||||||
|
const orgs = new Map<string, SessionHandle>();
|
||||||
|
let context: 'personal' | string = 'personal';
|
||||||
|
|
||||||
|
export function setPersonal(h: SessionHandle): void { personal = h; }
|
||||||
|
export function getPersonal(): SessionHandle | null { return personal; }
|
||||||
|
export function setOrg(orgId: string, h: SessionHandle): void { orgs.set(orgId, h); }
|
||||||
|
export function getOrg(orgId: string): SessionHandle | null { return orgs.get(orgId) ?? null; }
|
||||||
|
export function setContext(c: 'personal' | string): void { context = c; }
|
||||||
|
export function currentContext(): 'personal' | string { return context; }
|
||||||
|
|
||||||
|
export function requireCurrentHandle(): SessionHandle {
|
||||||
|
const h = context === 'personal' ? personal : orgs.get(context) ?? null;
|
||||||
|
if (!h) throw new Error('vault_locked');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAll(): void {
|
||||||
|
if (personal) { personal.free(); personal = null; }
|
||||||
|
for (const [, h] of orgs) h.free();
|
||||||
|
orgs.clear();
|
||||||
|
context = 'personal';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back-compat wrappers so existing personal-vault callers compile unchanged:
|
||||||
|
export function setCurrent(h: SessionHandle): void { setPersonal(h); }
|
||||||
|
export function getCurrent(): SessionHandle | null { return getPersonal(); }
|
||||||
|
export function requireCurrent(): SessionHandle { if (!personal) throw new Error('vault_locked'); return personal; }
|
||||||
|
export function clearCurrent(): void { clearAll(); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-session.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Point lock + timer at `clearAll`**
|
||||||
|
|
||||||
|
In `router/popup-only.ts` (the `lock` handler) and `index.ts` (`onExpired`), confirm they call `session.clearCurrent()` — now aliased to `clearAll()` — so a lock or timeout zeroes every org handle too. Run the full SW suite: `cd extension && npx vitest run src/service-worker/`. Expected: green (no personal regressions).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all` (NOT `npx tsc` — it can't resolve the generated wasm module).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/service-worker/session.ts extension/src/service-worker/index.ts extension/src/service-worker/router/popup-only.ts
|
||||||
|
git commit -m "feat(ext/sw): multi-context session (personal + orgs), clearAll zeroes all"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Org config storage + `org_list_configs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `extension/src/service-worker/org-config.ts`
|
||||||
|
- Modify: `extension/src/shared/messages.ts`, `extension/src/shared/types.ts`, `extension/src/service-worker/router/org-handlers.ts` (new), `router/popup-only.ts`
|
||||||
|
- Test: `extension/src/service-worker/__tests__/org-config.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `type OrgConfig = { orgId: string; displayName: string; hostType: 'gitea'|'github'; hostUrl: string; repoPath: string; apiToken: string; memberId: string }`; `type OrgConfigSummary = { orgId: string; displayName: string }`; `loadOrgConfigs(): Promise<OrgConfig[]>`; SW message `org_list_configs → { ok, data: OrgConfigSummary[] }`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('org_list_configs returns id+displayName only (no tokens)', async () => {
|
||||||
|
chrome.storage.local.get = vi.fn().mockResolvedValue({ orgConfigs: [
|
||||||
|
{ orgId: 'o1', displayName: 'Acme', hostType: 'gitea', hostUrl: 'h', repoPath: 'r', apiToken: 'SECRET', memberId: 'm1' },
|
||||||
|
]});
|
||||||
|
const resp = await handleOrgListConfigs();
|
||||||
|
expect(resp).toEqual({ ok: true, data: [{ orgId: 'o1', displayName: 'Acme' }] });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-config.test.ts`
|
||||||
|
Expected: FAIL — `handleOrgListConfigs` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `org-config.ts` + handler**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// org-config.ts
|
||||||
|
export type OrgConfig = { orgId: string; displayName: string; hostType: 'gitea'|'github'; hostUrl: string; repoPath: string; apiToken: string; memberId: string };
|
||||||
|
export async function loadOrgConfigs(): Promise<OrgConfig[]> {
|
||||||
|
const { orgConfigs } = await chrome.storage.local.get('orgConfigs');
|
||||||
|
return (orgConfigs as OrgConfig[] | undefined) ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// org-handlers.ts
|
||||||
|
import { loadOrgConfigs } from '../org-config';
|
||||||
|
export async function handleOrgListConfigs() {
|
||||||
|
const cfgs = await loadOrgConfigs();
|
||||||
|
return { ok: true as const, data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })) };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire the message (all three places)**
|
||||||
|
|
||||||
|
Add `org_list_configs` to the `PopupMessage` union and `POPUP_ONLY_TYPES` in `shared/messages.ts`, and a dispatch arm in `router/popup-only.ts` → `handleOrgListConfigs()`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-config.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/service-worker/org-config.ts extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts extension/src/shared/types.ts
|
||||||
|
git commit -m "feat(ext/sw): org config storage + org_list_configs message"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Org read core — load grants, unwrap, fetch + grant-filter manifest
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `extension/src/service-worker/org-vault.ts`
|
||||||
|
- Modify: `extension/src/shared/types.ts` (`Collection`, `OrgMember`)
|
||||||
|
- Test: `extension/src/service-worker/__tests__/org-vault.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `chrome.storage.local.device_private_key`; `wasm.manifest_decrypt` (existing).
|
||||||
|
- Produces: `openOrg(cfg: OrgConfig): Promise<OrgHandleState>` where `OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }`; `listOrgItems(state): ManifestEntry[]` (filtered to `grants`); `getOrgItem(state, id): Promise<Item>`; `listOrgCollections(state): Collection[]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (mock the GitHost + wasm boundary as `router.test.ts` does)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('listOrgItems hides entries for ungranted collections', () => {
|
||||||
|
const manifest = { items: {
|
||||||
|
a: { id: 'a', title: 'x', collection: 'prod-infra', modified: 1 },
|
||||||
|
b: { id: 'b', title: 'y', collection: 'secret-ops', modified: 1 },
|
||||||
|
}};
|
||||||
|
const state = { handle: {} as any, grants: ['prod-infra'], offline: false };
|
||||||
|
expect(listOrgItems(state, manifest).map(e => e.id)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts`
|
||||||
|
Expected: FAIL — `listOrgItems` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `org-vault.ts`** (open flow + filters)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createGitHost } from './git-host';
|
||||||
|
import { fingerprint } from '../shared/ssh-fingerprint';
|
||||||
|
import type { OrgConfig } from './org-config';
|
||||||
|
|
||||||
|
export type OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean };
|
||||||
|
|
||||||
|
export async function openOrg(cfg: OrgConfig, wasm: WasmModule): Promise<OrgHandleState> {
|
||||||
|
const host = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken);
|
||||||
|
const members = JSON.parse(new TextDecoder().decode(await host.readFile('members.json')));
|
||||||
|
const { device_private_key } = await chrome.storage.local.get('device_private_key');
|
||||||
|
const me = matchMember(members, await deviceFingerprint()); // by ed25519 fingerprint
|
||||||
|
if (!me) throw new Error('not_an_org_member');
|
||||||
|
const wrapped = await host.readFile(`keys/${me.member_id}.enc`);
|
||||||
|
const handle = wasm.org_unwrap_key(wrapped, device_private_key);
|
||||||
|
return { handle, grants: me.collections, offline: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOrgItems(state: OrgHandleState, manifest: Manifest): ManifestEntry[] {
|
||||||
|
return Object.values(manifest.items)
|
||||||
|
.filter(e => e.collection && state.grants.includes(e.collection));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Fetch-and-decrypt the manifest with `wasm.manifest_decrypt(state.handle, ct)`, mirroring `vault.ts fetchAndDecryptManifest`. `getOrgItem` reads `items/<collection>/<id>.enc` and `wasm.item_decrypt`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the "key never persisted" assertion test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('opening an org never writes the org key to storage', async () => {
|
||||||
|
const setSpy = vi.spyOn(chrome.storage.local, 'set');
|
||||||
|
await openOrg(fakeCfg, fakeWasm);
|
||||||
|
for (const call of setSpy.mock.calls) {
|
||||||
|
expect(JSON.stringify(call)).not.toContain('orgMasterKey');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it; expected PASS (we never call `storage.local.set` with the key).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/service-worker/org-vault.ts extension/src/shared/types.ts extension/src/service-worker/__tests__/org-vault.test.ts
|
||||||
|
git commit -m "feat(ext/sw): org-vault — unwrap, fetch, grant-filter manifest"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: `org_switch` (with offline detection) + read messages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/service-worker/router/org-handlers.ts`, `router/popup-only.ts`, `shared/messages.ts`
|
||||||
|
- Test: `extension/src/service-worker/__tests__/org-vault.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces SW messages: `org_switch {context}` → `{ ok, data: { context, offline } }`; `org_list_items` → `{ ok, data: ManifestEntry[] }`; `org_get_item {id}` → `{ ok, data: Item }`; `org_list_collections` → `{ ok, data: Collection[] }`. On a git network error during switch, set `offline: true` and serve the last-cached manifest read-only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('org_switch flags offline when the git fetch throws a network error', async () => {
|
||||||
|
const resp = await handleOrgSwitch({ context: 'o1' }, { ...stateWithNetworkError });
|
||||||
|
expect(resp).toEqual({ ok: true, data: { context: 'o1', offline: true } });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts`
|
||||||
|
Expected: FAIL — `handleOrgSwitch` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the four handlers** (switch sets `session.setContext`, caches the org `OrgHandleState`; on network error reuse the cached manifest and return `offline: true`; the three read handlers project from the cached state via `listOrgItems`/`getOrgItem`/`listOrgCollections`). Wire all four messages in `shared/messages.ts` (union + `POPUP_ONLY_TYPES`) and `popup-only.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/service-worker/`
|
||||||
|
Expected: PASS (all org + personal SW tests green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts
|
||||||
|
git commit -m "feat(ext/sw): org_switch + org read messages (grant-filtered, offline-aware)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hand-off contract (consumed by Plan 2 read UI and Plan 3 write)
|
||||||
|
|
||||||
|
Plans 2 and 3 are UI-only and talk to the SW exclusively through these messages (sent via the `shared/state.ts` `sendMessage` wrapper from `popup.html`/`vault.html`):
|
||||||
|
|
||||||
|
- `org_list_configs` → `{ ok, data: OrgConfigSummary[] }` where `OrgConfigSummary = { orgId, displayName }`
|
||||||
|
- `org_switch { context: 'personal' | <orgId> }` → `{ ok, data: { context, offline: boolean } }`
|
||||||
|
- `org_list_items` → `{ ok, data: ManifestEntry[] }` (already grant-filtered; entries carry `collection`)
|
||||||
|
- `org_get_item { id }` → `{ ok, data: Item }`
|
||||||
|
- `org_list_collections` → `{ ok, data: Collection[] }` where `Collection = { slug, display_name }`
|
||||||
|
|
||||||
|
The SW holds the org context after `org_switch`; subsequent `org_list_items`/`org_get_item` operate on the current context until the next `org_switch` (including back to `'personal'`). Plan 3 adds `org_add_item`/`org_update_item`/`org_delete_item` against this same context model.
|
||||||
300
docs/superpowers/plans/2026-06-20-v0.9.0-org-b-read-ui.md
Normal file
300
docs/superpowers/plans/2026-06-20-v0.9.0-org-b-read-ui.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Org Read UI 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 (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let a member browse and view org items in the browser — a context switcher (Personal + each org), grant-filtered list/detail reusing the existing renderers, and an offline indicator.
|
||||||
|
|
||||||
|
**Architecture:** UI-only. All org data comes from the SW messages Plan 1 produced, sent through the `shared/state.ts` `sendMessage` channel. The current context lives in `PopupState.orgContext`; list/detail data-loading branches on it (`list_items` vs `org_list_items`, `get_item` vs `org_get_item`) but reuses the same `popup/components/*` renderers via the `StateHost` service locator, so org items render with the unchanged per-type detail views.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, vitest + happy-dom.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Release target: v0.9.0.
|
||||||
|
- Reuse existing `popup/components/*` renderers via `shared/state.ts` — do NOT fork per-type views for org.
|
||||||
|
- This plan is READ-ONLY: no add/edit/delete UI (Plan 3). In org context, hide write affordances.
|
||||||
|
- Org messages are popup-class (sent only from `popup.html` / `vault.html`).
|
||||||
|
- Consume Plan 1's contract verbatim: `org_list_configs`, `org_switch {context}`, `org_list_items`, `org_get_item {id}`, `org_list_collections`.
|
||||||
|
- Keep `manifest.json` and `manifest.firefox.json` in sync if permissions change (they should not for this plan).
|
||||||
|
- Capitalize "Relicario" in prose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `extension/src/shared/popup-state.ts` — add `orgContext`, `orgConfigs`, `orgCollections`, `orgOffline` to `PopupState`.
|
||||||
|
- `extension/src/shared/org-context.ts` *(new)* — `currentContext()`, `messageForList()`, `messageForGet(id)` helpers that pick the personal vs org message by context (single source of truth, consumed by list + detail).
|
||||||
|
- `extension/src/popup/components/org-switcher.ts` *(new)* — the Personal/org selector + offline banner; mounted in both the popup header and the vault sidebar header.
|
||||||
|
- `extension/src/popup/components/item-list.ts` — load via `messageForList()`; hide the "+ new" affordance in org context.
|
||||||
|
- `extension/src/popup/components/item-detail.ts` — load via `messageForGet(id)`.
|
||||||
|
- `extension/src/vault/vault-sidebar.ts` — mount `org-switcher` in `vault-sidebar__header`; add a collection facet for org context.
|
||||||
|
- `extension/src/popup/popup.ts` — mount `org-switcher` in the popup header.
|
||||||
|
- Tests: `extension/src/popup/components/__tests__/org-switcher.test.ts`, `org-context.test.ts`, and additions to `item-list.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `PopupState` org fields + context message helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/shared/popup-state.ts`
|
||||||
|
- Create: `extension/src/shared/org-context.ts`
|
||||||
|
- Test: `extension/src/shared/__tests__/org-context.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `PopupState.orgContext: 'personal' | string` (default `'personal'`), `orgConfigs: OrgConfigSummary[]`, `orgCollections: Collection[]`, `orgOffline: boolean`; `currentContext(): 'personal' | string`; `messageForList(): Request`; `messageForGet(id: string): Request`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { messageForList, messageForGet } from '../org-context';
|
||||||
|
import * as state from '../state';
|
||||||
|
|
||||||
|
test('messageForList/Get pick personal vs org by current context', () => {
|
||||||
|
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'personal' } as any);
|
||||||
|
expect(messageForList()).toEqual({ type: 'list_items' });
|
||||||
|
expect(messageForGet('x')).toEqual({ type: 'get_item', id: 'x' });
|
||||||
|
|
||||||
|
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1' } as any);
|
||||||
|
expect(messageForList()).toEqual({ type: 'org_list_items' });
|
||||||
|
expect(messageForGet('x')).toEqual({ type: 'org_get_item', id: 'x' });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/shared/__tests__/org-context.test.ts`
|
||||||
|
Expected: FAIL — `org-context` module missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// org-context.ts
|
||||||
|
import { getState } from './state';
|
||||||
|
import type { Request } from './messages';
|
||||||
|
|
||||||
|
export function currentContext(): 'personal' | string {
|
||||||
|
return getState().orgContext ?? 'personal';
|
||||||
|
}
|
||||||
|
export function messageForList(): Request {
|
||||||
|
return currentContext() === 'personal' ? { type: 'list_items' } : { type: 'org_list_items' };
|
||||||
|
}
|
||||||
|
export function messageForGet(id: string): Request {
|
||||||
|
return currentContext() === 'personal' ? { type: 'get_item', id } : { type: 'org_get_item', id };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the four fields to `PopupState` in `popup-state.ts` (defaults: `orgContext: 'personal'`, `orgConfigs: []`, `orgCollections: []`, `orgOffline: false`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/shared/__tests__/org-context.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/shared/popup-state.ts extension/src/shared/org-context.ts extension/src/shared/__tests__/org-context.test.ts
|
||||||
|
git commit -m "feat(ext): PopupState org fields + context-aware message helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Org switcher component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `extension/src/popup/components/org-switcher.ts`
|
||||||
|
- Test: `extension/src/popup/components/__tests__/org-switcher.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `org_list_configs`, `org_switch` (via `sendMessage`); `setState`, `navigate`.
|
||||||
|
- Produces: `renderOrgSwitcher(host: HTMLElement): Promise<void>` (renders a `<select>` of Personal + each org, an offline badge), `teardown()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (mock `shared/state`, the established component-test pattern)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderOrgSwitcher } from '../org-switcher';
|
||||||
|
import * as state from '../../../shared/state';
|
||||||
|
|
||||||
|
test('switching to an org sends org_switch and reloads the list', async () => {
|
||||||
|
const send = vi.spyOn(state, 'sendMessage').mockImplementation(async (req: any) => {
|
||||||
|
if (req.type === 'org_list_configs') return { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] };
|
||||||
|
if (req.type === 'org_switch') return { ok: true, data: { context: 'org-1', offline: false } };
|
||||||
|
return { ok: true, data: [] };
|
||||||
|
});
|
||||||
|
const nav = vi.spyOn(state, 'navigate').mockImplementation(() => {});
|
||||||
|
const host = document.createElement('div');
|
||||||
|
await renderOrgSwitcher(host);
|
||||||
|
const sel = host.querySelector('select') as HTMLSelectElement;
|
||||||
|
sel.value = 'org-1'; sel.dispatchEvent(new Event('change'));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(send).toHaveBeenCalledWith({ type: 'org_switch', context: 'org-1' });
|
||||||
|
expect(nav).toHaveBeenCalledWith('list', expect.anything());
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts`
|
||||||
|
Expected: FAIL — `org-switcher` missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — fetch `org_list_configs`, render `<select>` (Personal + each), on `change` send `org_switch`, write `setState({ orgContext, orgOffline })`, then `navigate('list', {})` to reload. Render an "org offline — writes disabled" badge when `data.offline`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Mount in both surfaces + commit** — call `renderOrgSwitcher` into the popup header (`popup.ts`) and the `vault-sidebar__header` (`vault-sidebar.ts`, after the brand block at `:26-29`).
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/popup/components/org-switcher.ts extension/src/popup/popup.ts extension/src/vault/vault-sidebar.ts extension/src/popup/components/__tests__/org-switcher.test.ts
|
||||||
|
git commit -m "feat(ext): org context switcher (popup header + vault sidebar)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: List + detail consume the context-aware data source
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/components/item-list.ts`, `item-detail.ts`
|
||||||
|
- Test: additions to `extension/src/popup/components/__tests__/item-list.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `messageForList()` / `messageForGet(id)` (Task 1).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('item-list loads org items (grant-filtered) when context is an org', async () => {
|
||||||
|
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', items: [] } as any);
|
||||||
|
const send = vi.spyOn(state, 'sendMessage').mockResolvedValue({ ok: true, data: [
|
||||||
|
{ id: 'a', title: 'db', collection: 'prod-infra', modified: 1 },
|
||||||
|
]});
|
||||||
|
await renderItemList(document.createElement('div'));
|
||||||
|
expect(send).toHaveBeenCalledWith({ type: 'org_list_items' });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/popup/components/__tests__/item-list.test.ts -t "org items"`
|
||||||
|
Expected: FAIL — list still sends `list_items`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — replace the hard-coded `sendMessage({ type: 'list_items' })` in `item-list.ts` with `sendMessage(messageForList())`, and `get_item` in `item-detail.ts` with `messageForGet(id)`. In org context, hide the "+ new item" button (read-only this plan).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/popup/components/__tests__/item-list.test.ts`
|
||||||
|
Expected: PASS (personal path unchanged: context `'personal'` → `list_items`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check + commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/popup/components/item-list.ts extension/src/popup/components/item-detail.ts extension/src/popup/components/__tests__/item-list.test.ts
|
||||||
|
git commit -m "feat(ext): list/detail load org items in org context"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Collection facet in the vault sidebar (org context)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/vault/vault-sidebar.ts`, `extension/src/vault/vault-context.ts` (filter helper)
|
||||||
|
- Test: `extension/src/popup/components/__tests__/org-switcher.test.ts` (extend) or a new `vault-sidebar` test
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `org_list_collections`; `PopupState.orgCollections`.
|
||||||
|
- Produces: a collection nav list (parallel to the type-category nav) shown only in org context; selecting a collection filters the org list to that slug.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('org context renders a collection facet from org_list_collections', async () => {
|
||||||
|
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgCollections: [
|
||||||
|
{ slug: 'prod-infra', display_name: 'Production Infra' },
|
||||||
|
]} as any);
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderCollectionFacet(el);
|
||||||
|
expect(el.textContent).toContain('Production Infra');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run -t "collection facet"`
|
||||||
|
Expected: FAIL — `renderCollectionFacet` missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — on `org_switch` success, fetch `org_list_collections` into `state.orgCollections`; render a collection list in the sidebar (reuse the category-nav markup pattern at `vault-sidebar.ts:33`); clicking a collection sets a `collectionFilter` in state and re-renders the filtered list. Hidden when `orgContext === 'personal'`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run -t "collection facet"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/vault/vault-sidebar.ts extension/src/vault/vault-context.ts
|
||||||
|
git commit -m "feat(ext): collection facet for org browse"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Offline read-only banner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/components/org-switcher.ts` (or a small `org-banner.ts`)
|
||||||
|
- Test: `extension/src/popup/components/__tests__/org-switcher.test.ts` (extend)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `org_switch` response `{ offline }`; `PopupState.orgOffline`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('offline org_switch renders the writes-disabled banner', async () => {
|
||||||
|
vi.spyOn(state, 'sendMessage').mockImplementation(async (req: any) =>
|
||||||
|
req.type === 'org_switch' ? { ok: true, data: { context: 'org-1', offline: true } }
|
||||||
|
: req.type === 'org_list_configs' ? { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] }
|
||||||
|
: { ok: true, data: [] });
|
||||||
|
const host = document.createElement('div');
|
||||||
|
await renderOrgSwitcher(host);
|
||||||
|
(host.querySelector('select') as HTMLSelectElement).value = 'org-1';
|
||||||
|
host.querySelector('select')!.dispatchEvent(new Event('change'));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(host.textContent).toContain('org offline — writes disabled');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run -t "writes-disabled banner"`
|
||||||
|
Expected: FAIL — no banner.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — when `org_switch` returns `offline: true`, set `state.orgOffline` and render the banner element in the switcher host. (Plan 3's write UI reads `orgOffline` to disable add/edit.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full suite, type-check, commit**
|
||||||
|
|
||||||
|
Run: `cd extension && npx vitest run && npm run build:all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/popup/components/org-switcher.ts extension/src/popup/components/__tests__/org-switcher.test.ts
|
||||||
|
git commit -m "feat(ext): org offline read-only banner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hand-off note (Plan 3 write builds on this)
|
||||||
|
|
||||||
|
Plan 3 adds the write affordances this plan deliberately hid: the "+ new item" button in org context, edit/delete in the org item detail, and a granted-collection picker on add. It reads `PopupState.orgOffline` to disable writes when offline, and `PopupState.orgCollections` for the collection picker. Write operations call the `org_add_item`/`org_update_item`/`org_delete_item` messages Plan 3 adds to the SW.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user