Compare commits
97 Commits
8e791e4853
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2e41e0bae0 | ||
|
|
03f2a1b58e | ||
|
|
e5d63ab196 |
678
.claude/workflows/release.js
Normal file
678
.claude/workflows/release.js
Normal file
@@ -0,0 +1,678 @@
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 }
|
||||
209
CHANGELOG.md
209
CHANGELOG.md
@@ -1,5 +1,210 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
Three release trains roll into one tag — backup/restore + LastPass
|
||||
@@ -135,12 +340,12 @@ two confirmed bugs).
|
||||
the `.form-grid` cards above. Removes the visual rhythm break at the
|
||||
2-col → full-width transition. The popup surface is unchanged.
|
||||
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
|
||||
`docs/architecture/overview.md` now describes four codebases (the
|
||||
`DESIGN.md` now describes four codebases (the
|
||||
`relicario-server` pre-receive hook crate is no longer invisible);
|
||||
`CLAUDE.md` project tree and roadmap reflect current state;
|
||||
`docs/SECURITY.md` names the server crate and its `verify-commit` /
|
||||
`generate-hook` subcommands and notes the without-the-hook-it's-
|
||||
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
|
||||
advisory caveat; `docs/CRYPTO.md` shows `settings.enc` as a
|
||||
parallel artifact in the vault-creation flow; the foundational
|
||||
design spec gains a "historical" status banner pointing readers at
|
||||
the current docs.
|
||||
|
||||
73
CLAUDE.md
73
CLAUDE.md
@@ -86,10 +86,75 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
||||
|
||||
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
|
||||
|
||||
## Design spec
|
||||
## Planning & design specs
|
||||
|
||||
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-relicario-design.md`
|
||||
**Before starting any planning or implementation task**, search `docs/superpowers/specs/` for a spec covering the feature area, and `docs/superpowers/plans/` for any existing implementation plan. The specs are the authoritative design record; plans track per-milestone implementation details. 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
|
||||
Core references (read before touching crypto, data model, or architecture):
|
||||
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — threat model, entropy analysis, crypto pipeline, crate layout
|
||||
- `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — typed-item data model and envelope
|
||||
- `docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md` — fullscreen UX phase plan
|
||||
|
||||
Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.
|
||||
After completing any dev iteration, update `STATUS.md` to reflect what shipped and what's now in flight. Update the component doc for any area you changed (see table below).
|
||||
|
||||
## 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.
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "relicario-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2185,7 +2185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-core"
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64",
|
||||
@@ -2231,7 +2231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-wasm"
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Architecture overview — Relicario
|
||||
|
||||
> **Audience:** anyone wanting to understand the system at the cross-codebase level. This doc owns the four-codebase map, inter-codebase contracts, the secrets map (what secret lives where), the build matrix, and the global code-map index. **Does NOT own:** crypto pipeline details (see [docs/CRYPTO.md](docs/CRYPTO.md)), wire formats (see [docs/FORMATS.md](docs/FORMATS.md)), threat model (see [docs/SECURITY.md](docs/SECURITY.md)), per-crate module maps (see [crates/relicario-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md), [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md), and [extension/ARCHITECTURE.md](extension/ARCHITECTURE.md)).
|
||||
|
||||
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
||||
|
||||
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
|
||||
>
|
||||
> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md)
|
||||
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md)
|
||||
> - [extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md)
|
||||
> - [crates/relicario-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md)
|
||||
> - [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md)
|
||||
> - [extension/ARCHITECTURE.md](extension/ARCHITECTURE.md)
|
||||
>
|
||||
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
||||
|
||||
@@ -196,10 +198,10 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|
||||
|
||||
| If you're working on... | Start with |
|
||||
|---|---|
|
||||
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/ARCHITECTURE.md) |
|
||||
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) |
|
||||
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
|
||||
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
|
||||
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](crates/relicario-core/ARCHITECTURE.md) |
|
||||
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](crates/relicario-cli/ARCHITECTURE.md) |
|
||||
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](extension/ARCHITECTURE.md) |
|
||||
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](extension/ARCHITECTURE.md) |
|
||||
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
|
||||
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
|
||||
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
|
||||
@@ -211,3 +213,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|
||||
## Stale spec docs
|
||||
|
||||
The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts).
|
||||
|
||||
---
|
||||
|
||||
**Next:** [docs/CRYPTO.md](docs/CRYPTO.md) — the crypto pipeline that backs this design.
|
||||
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
|
||||
|
||||
> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md).
|
||||
|
||||
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
|
||||
|
||||
The server only ever sees opaque ciphertext. There is nothing else going on. This README is the security proof.
|
||||
@@ -89,6 +91,12 @@ relicario list
|
||||
# Sync with your git remote
|
||||
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
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
A short tour of the four codebases and how they fit together lives in [DESIGN.md](DESIGN.md). Crypto pipeline diagrams are in [docs/CRYPTO.md](docs/CRYPTO.md); the wire format reference is [docs/FORMATS.md](docs/FORMATS.md); the threat model is [docs/SECURITY.md](docs/SECURITY.md).
|
||||
|
||||
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
|
||||
`relicario-core` is the platform-agnostic bytes-in/bytes-out heart — no filesystem, no network. The CLI binary and the browser-extension WASM bridge both consume it. See per-codebase deep-dives in `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md`.
|
||||
|
||||
### Crypto primitives
|
||||
|
||||
@@ -206,6 +210,7 @@ The binary is at `target/release/relicario`.
|
||||
- [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP
|
||||
- [x] Secure document storage (encrypted file attachments)
|
||||
- [x] Backup & restore (`.relbak` encrypted envelope)
|
||||
- [x] Recovery QR (paper-printable image_secret backup with separate passphrase)
|
||||
- [x] LastPass CSV import
|
||||
- [x] Device authentication (ed25519 commit signing + pre-receive hook)
|
||||
- [ ] Import from Bitwarden / 1Password
|
||||
@@ -215,8 +220,12 @@ The binary is at `target/release/relicario`.
|
||||
|
||||
## 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.
|
||||
|
||||
38
ROADMAP.md
Normal file
38
ROADMAP.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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.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 now shipped (CLI restructure = Plan B Cycles 1+2; security polish = Stream A Cycle 1; extension restructure = Plan C Phases 1–6, completed v0.7.0 2026-06-01). The next committed item is:
|
||||
|
||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||
|
||||
## Medium-term
|
||||
|
||||
_(promote here once specced)_
|
||||
|
||||
## 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`.
|
||||
148
STATUS.md
Normal file
148
STATUS.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 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:** **extension restructure (Plan C) — COMPLETE.** All six phases merged. Phases 1, 2, 5 merged 2026-05-30; Phases 3, 4, 6 merged 2026-05-31/06-01 via three parallel worktree streams (Dev-A/B/C under PM coordination). Versions bumped to v0.7.0; tag pending.
|
||||
|
||||
## What landed on main since the v0.5.0 version bump
|
||||
|
||||
### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md`
|
||||
Plan: `docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md`
|
||||
|
||||
- Patina gold palette tokens (`--gold-base` `#a88a4a`, `--gold-mid`, `--gold-shadow`, etc.) replacing the bright amber `#d2ab43`
|
||||
- `.surface-backdrop` (radial top-glow + 18px grid texture) on popup body, setup body, vault body
|
||||
- `.glass` card class with `backdrop-filter: blur(8px)` for unlock card, setup steps, form columns
|
||||
- `.btn-primary` / `.btn-secondary` button hierarchy alongside existing `.btn`
|
||||
- `GLYPH_NEXT = '▸'` (U+25B8) replacing ASCII `→` in next/continue buttons
|
||||
- Unlock view restructure: logo-lockup (logo + brand + tagline) + glass card + primary "unlock vault" button + secondary open-vault/settings demoted
|
||||
- Setup wizard: backdrop + glass step cards + glass mode-picker cards + ▸ on next buttons
|
||||
- Two-column login form (`surface: 'popup' | 'fullscreen'` flag on `renderForm`)
|
||||
- Sticky save bar in fullscreen forms with `externalActions` flag
|
||||
- Form header with title + dirty-state subtitle + platform-aware save hint (⌘+S / Ctrl+S)
|
||||
|
||||
### v0.5.1 Stream A — fullscreen + popup layout polish (merged 2026-05-03, `c16adc4`)
|
||||
|
||||
- 3-column vault tab: sidebar (200px) + list (flex) + detail drawer (440px)
|
||||
- Sidebar type-category nav replacing flat item list (All items + per-type counts)
|
||||
- Bottom sheet for "new item" type picker (pane-only scrim, sidebar stays interactive)
|
||||
- Shared toast system at `extension/src/shared/toast.ts` (`showToast(message, type, durationMs)`)
|
||||
- `GLYPH_VAULT_TAB = '⧉'` (U+29C9) replacing `⤴` 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`)
|
||||
|
||||
### 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.
|
||||
|
||||
Beyond extension restructure, ROADMAP medium-term holds Phase 4 command palette (no spec yet). Long-term: relay server, mobile.
|
||||
|
||||
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers this extension-restructure completion).
|
||||
@@ -1,5 +1,7 @@
|
||||
# Architecture: relicario-cli
|
||||
|
||||
> **Audience:** contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. **Does NOT own:** crypto, wire formats, or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/FORMATS.md](../../docs/FORMATS.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)).
|
||||
|
||||
## What this crate is for
|
||||
|
||||
The `relicario` binary is the platform layer for `relicario-core`: it adds
|
||||
@@ -16,22 +18,46 @@ locally, and lets recovery debugging happen with familiar tooling.
|
||||
|
||||
## Module map
|
||||
|
||||
The crate is three files of source and a `tests/` directory. Each source file
|
||||
has one job.
|
||||
`src/main.rs` is now a thin clap-surface + dispatcher; per-command logic lives
|
||||
under `src/commands/`. Each source file has one job.
|
||||
|
||||
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
|
||||
handler. Internal structure: a top-level `Cli` / `Commands` enum
|
||||
(`main.rs:13-275`), a flat dispatcher `match` in `main()`
|
||||
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
|
||||
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
|
||||
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
|
||||
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
|
||||
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
|
||||
can be tested through the same integration paths. Owns all clap argument
|
||||
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
|
||||
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
|
||||
`commit_paths` helper that is the single chokepoint for git commits during
|
||||
vault mutations.
|
||||
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
|
||||
Owns the top-level `Cli` / `Commands` enum and every subcommand enum
|
||||
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
|
||||
`DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that
|
||||
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
|
||||
three test-only env-var hooks (`test_passphrase_override`,
|
||||
`test_item_secret_override`, `test_backup_passphrase_override`) — each is
|
||||
stripped from release builds via `#[cfg(debug_assertions)]`.
|
||||
|
||||
- **`src/commands/`** — one module per top-level command. `mod.rs` re-exports
|
||||
the public surface and hosts the shared `commit_paths` helper (the single
|
||||
chokepoint for git commits during vault mutations) plus other cross-command
|
||||
glue. Per-command modules: `init`, `add`, `get`, `list` (also hosts
|
||||
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
|
||||
`backup` (export / restore), `import` (lastpass), `attach` (attach /
|
||||
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
|
||||
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
|
||||
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
|
||||
builder/editor reads top-to-bottom and can be tested through the same
|
||||
integration paths.
|
||||
|
||||
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
|
||||
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
|
||||
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
|
||||
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
|
||||
|
||||
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
|
||||
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
|
||||
|
||||
- **`src/device.rs`** — device-management plumbing called by
|
||||
`commands::device`: ed25519 keypair generation via `relicario-core::device`,
|
||||
on-disk layout under `<config_dir>/relicario/devices/<name>/`, and the
|
||||
read/write of `.relicario/devices.json` / `revoked.json`.
|
||||
|
||||
- **`src/gitea.rs`** — minimal Gitea REST client used by `commands::device add`
|
||||
/ `revoke` to register and remove deploy keys. Reads
|
||||
`RELICARIO_GITEA_{URL,TOKEN,OWNER,REPO}` env vars (overridable via CLI flags).
|
||||
|
||||
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
|
||||
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
|
||||
@@ -306,13 +332,65 @@ rewrite `devices.json`, commit `device: revoke <name>`. Note that device
|
||||
keys are kept entirely separate from the KDF (passphrase × image stays
|
||||
unchanged across device add/revoke), as per the design spec.
|
||||
|
||||
### Backup-passphrase-style commands (none yet)
|
||||
### Backup (`commands::backup`, `commands/backup.rs`)
|
||||
|
||||
The import / export / `import-lastpass` commands described in
|
||||
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
|
||||
not yet implemented. When they land they'll fit in the dispatcher
|
||||
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here
|
||||
until that work begins.
|
||||
Two subcommands, both keyed by a *backup* passphrase that is independent of
|
||||
the vault master passphrase.
|
||||
|
||||
- **`backup export <out> [--include-image] [--image PATH] [--no-history]`** —
|
||||
reads the entire on-disk vault layout (`.relicario/{salt,params.json,
|
||||
devices.json}`, `manifest.enc`, `settings.enc`, every `items/*.enc`, every
|
||||
`attachments/<iid>/<aid>.enc`), optionally bundles the reference JPEG and
|
||||
the `.git/` directory (as an in-memory tar), and hands the lot to
|
||||
`relicario_core::backup::pack_backup` with a zxcvbn-gated backup
|
||||
passphrase prompted twice. The resulting `.relbak` is written via
|
||||
`tmp` + rename. A `.relicario/last_backup` marker file (ISO-8601 line) is
|
||||
also written so `cmd_status` can show "last backup at …".
|
||||
- **`backup restore <input> [<target>]`** — refuses to overwrite an existing
|
||||
vault (`target/.relicario/` must not exist). Unpacks the `.relbak` via
|
||||
`unpack_backup`, then materialises every byte into the target layout. The
|
||||
bundled `.git/` tar is extracted via the hardened
|
||||
`relicario_core::safe_unpack_git_archive` (path-traversal / symlink /
|
||||
size-cap guards) with a cap of `min(100 × tar_size, 1 GiB)`; if no
|
||||
history was bundled, the target gets a fresh `git init` + initial commit.
|
||||
|
||||
### Import (`commands::import`, `commands/import.rs`)
|
||||
|
||||
- **`import lastpass <csv>`** — reads the CSV, calls
|
||||
`relicario_core::import_lastpass::parse_lastpass_csv`, then unlocks the
|
||||
vault and writes every produced `Item` through `vault.save_item` + manifest
|
||||
upsert. Failed rows surface as `ImportWarning`s on stderr and never abort
|
||||
the import; only a missing or malformed header is fatal. Commit message:
|
||||
`import: <N> items from LastPass (<csv-filename>)`. The dispatch shape
|
||||
(`ImportAction` subcommand enum) is in place for future importers
|
||||
(Bitwarden, 1Password, etc.) — each would add one `ImportAction` variant
|
||||
and one helper.
|
||||
|
||||
### Rate (`commands::rate`, `commands/rate.rs`)
|
||||
|
||||
`rate <passphrase|->` runs `relicario_core::generators::rate_passphrase`
|
||||
(zxcvbn-backed) and prints the 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
|
||||
|
||||
@@ -537,3 +615,7 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
|
||||
is why every `cmd_*` that takes a `query: String` (get, edit,
|
||||
history, rm, restore, purge, attach, attachments, extract, detach)
|
||||
works the same way.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "relicario-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
description = "CLI for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[[bin]]
|
||||
name = "relicario"
|
||||
|
||||
@@ -36,8 +36,7 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let mut paths: Vec<String> = vec![
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let paths = [
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
@@ -161,7 +161,7 @@ pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
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());
|
||||
|
||||
@@ -41,8 +41,7 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
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());
|
||||
|
||||
@@ -8,7 +8,6 @@ pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
vault.save_manifest(&manifest)?;
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
written_paths.push("manifest.enc".into());
|
||||
|
||||
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||
|
||||
@@ -65,17 +65,7 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
fs::write(relicario_dir.join("salt"), salt)?;
|
||||
fs::write(
|
||||
relicario_dir.join("params.json"),
|
||||
serde_json::to_string_pretty(&ParamsFile {
|
||||
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(),
|
||||
})?,
|
||||
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)?)?;
|
||||
@@ -106,20 +96,3 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ParamsFile {
|
||||
format_version: u32,
|
||||
kdf: ParamsKdf,
|
||||
aead: String,
|
||||
salt_path: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct ParamsKdf {
|
||||
algorithm: String,
|
||||
argon2_m: u32,
|
||||
argon2_t: u32,
|
||||
argon2_p: u32,
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ pub fn cmd_list(
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
|
||||
@@ -15,8 +15,7 @@ pub fn cmd_rm(query: String) -> Result<()> {
|
||||
item.soft_delete();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
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);
|
||||
@@ -33,37 +32,41 @@ pub fn cmd_restore(query: String) -> Result<()> {
|
||||
item.restore();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Inner purge: assumes vault is already unlocked and manifest is loaded.
|
||||
/// Caller is responsible for saving the manifest and committing afterwards.
|
||||
pub(super) fn purge_item(
|
||||
/// 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<()> {
|
||||
use std::fs;
|
||||
) -> Result<Vec<String>> {
|
||||
use std::{fs, io::ErrorKind};
|
||||
|
||||
let item_path = vault.item_path(id);
|
||||
if item_path.exists() { fs::remove_file(&item_path)?; }
|
||||
let att_dir = vault.root().join("attachments").join(id.as_str());
|
||||
if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
|
||||
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);
|
||||
|
||||
let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch",
|
||||
&format!("items/{}.enc", id.as_str()),
|
||||
&format!("attachments/{}", id.as_str()),
|
||||
]).status()?;
|
||||
// Note: caller adds+commits manifest.enc after processing all purges.
|
||||
eprintln!("Purged: {title}");
|
||||
Ok(())
|
||||
Ok(vec![item_rel, att_rel])
|
||||
}
|
||||
|
||||
pub fn cmd_purge(query: String) -> Result<()> {
|
||||
@@ -74,12 +77,16 @@ pub fn cmd_purge(query: String) -> Result<()> {
|
||||
let title = entry.title.clone();
|
||||
let _ = entry;
|
||||
|
||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
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_run(vault.root(), &["add", "manifest.enc"], &format!("{purge_ctx}: git add manifest.enc"))?;
|
||||
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())],
|
||||
@@ -116,13 +123,16 @@ pub fn cmd_trash_empty() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut purged_titles = Vec::new();
|
||||
let mut all_paths: Vec<String> = Vec::new();
|
||||
let purged_count = purgeable.len();
|
||||
for (id, title) in purgeable {
|
||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||
purged_titles.push(title);
|
||||
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||
all_paths.append(&mut paths);
|
||||
}
|
||||
|
||||
vault.save_manifest(&manifest)?;
|
||||
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"],
|
||||
@@ -130,10 +140,10 @@ pub fn cmd_trash_empty() -> Result<()> {
|
||||
)?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())],
|
||||
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
|
||||
"trash empty: git commit",
|
||||
)?;
|
||||
|
||||
eprintln!("Emptied trash: {} item(s)", purged_titles.len());
|
||||
eprintln!("Emptied trash: {} item(s)", purged_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -86,6 +86,16 @@ pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||
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.
|
||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||
/// a numeric string.
|
||||
@@ -126,6 +136,30 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
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`,
|
||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||
/// suppresses the write (developer debugging tool). In release builds the env
|
||||
|
||||
@@ -140,12 +140,13 @@ enum Commands {
|
||||
/// vault, falls back to `settings generator-defaults` for unspecified
|
||||
/// flags; outside a vault, uses built-in defaults (length 20, safe
|
||||
/// symbol set, 5 BIP39 words, space separator).
|
||||
#[command(alias = "gen")]
|
||||
Generate {
|
||||
#[arg(long)]
|
||||
#[arg(short = 'l', long)]
|
||||
length: Option<u32>,
|
||||
#[arg(long)]
|
||||
bip39: bool,
|
||||
#[arg(long)]
|
||||
#[arg(short = 'w', long)]
|
||||
words: Option<u32>,
|
||||
#[arg(long)]
|
||||
symbols: Option<String>,
|
||||
@@ -457,24 +458,6 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn refresh_groups_cache(vault_dir: &std::path::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 _ = helpers::write_groups_cache(vault_dir, &set);
|
||||
}
|
||||
|
||||
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||
|
||||
@@ -1,47 +1,19 @@
|
||||
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess).
|
||||
//!
|
||||
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and
|
||||
//! turns this file into a thin re-export shim. They live here for now so
|
||||
//! the Phase 1 relocation stays mechanical.
|
||||
//! 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::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use relicario_core::MonthYear;
|
||||
|
||||
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||
let month: u8 = m_str.parse().context("invalid month")?;
|
||||
let year: u16 = if y_str.len() == 2 {
|
||||
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
|
||||
} else {
|
||||
y_str.parse().context("invalid year")?
|
||||
};
|
||||
Ok(relicario_core::MonthYear { month, year })
|
||||
pub(crate) fn parse_month_year(s: &str) -> Result<MonthYear> {
|
||||
Ok(MonthYear::parse(s)?)
|
||||
}
|
||||
|
||||
pub(crate) fn guess_mime(filename: &str) -> String {
|
||||
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",
|
||||
}.to_string()
|
||||
relicario_core::mime::guess_for_extension(filename).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
|
||||
let cleaned: String = s.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.to_ascii_uppercase()
|
||||
.trim_end_matches('=')
|
||||
.to_string();
|
||||
let padded = {
|
||||
let rem = cleaned.len() % 8;
|
||||
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
|
||||
};
|
||||
data_encoding::BASE32.decode(padded.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
|
||||
Ok(relicario_core::base32::decode_rfc4648_lenient(s)?)
|
||||
}
|
||||
|
||||
@@ -69,9 +69,15 @@ impl UnlockedVault {
|
||||
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)?;
|
||||
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> {
|
||||
@@ -107,17 +113,52 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ParamsFile {
|
||||
kdf: KdfParams,
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct ParamsFile {
|
||||
pub format_version: u32,
|
||||
pub kdf: ParamsKdf,
|
||||
pub aead: String,
|
||||
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"))
|
||||
.context("failed to read .relicario/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.
|
||||
@@ -149,3 +190,78 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn generate_random_and_bip39() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Architecture: relicario-core
|
||||
|
||||
> **Audience:** contributors editing or extending `relicario-core`. This doc owns the module map for this crate, module-level invariants (e.g., no filesystem, no network), key flows at the module level, and the crate's test architecture. **Does NOT own:** crypto primitives or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)), wire formats (see [../../docs/FORMATS.md](../../docs/FORMATS.md)).
|
||||
|
||||
## What this crate is for
|
||||
|
||||
`relicario-core` is the platform-agnostic cryptographic and data-model heart of the
|
||||
@@ -101,6 +103,38 @@ Pipeline" and "Crate Layout").
|
||||
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
||||
Quantization Index Modulation, and crop-recovery extractor. No other module
|
||||
imports it; it is consumed only via the public re-export from `lib.rs`.
|
||||
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
|
||||
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
|
||||
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
|
||||
bytes (salt, params.json, devices.json, manifest, settings, items,
|
||||
attachments, optional reference JPEG, optional `.git/` tar) in an
|
||||
XChaCha20-Poly1305 envelope keyed by Argon2id over a user-chosen *backup*
|
||||
passphrase. The backup key is independent of any vault master key, and
|
||||
Argon2id parameters are pinned to the v1 values (m=64MiB, t=3, p=4) so a v1
|
||||
reader doesn't need to negotiate them.
|
||||
- **`import_lastpass.rs`** — `parse_lastpass_csv` plus `ImportWarning`. Pure
|
||||
bytes-in / `Vec<Item>`-out LastPass CSV importer: validates the fixed
|
||||
8-column header, mints fresh IDs and timestamps for each row, downgrades or
|
||||
skips malformed rows into `ImportWarning`s instead of aborting the import.
|
||||
Only fatal error is a missing/malformed header.
|
||||
- **`device.rs`** — Device-identity surface: `DeviceEntry`, `RevokedEntry`,
|
||||
`generate_keypair`, `sign`, `verify`, `fingerprint`. ed25519 in OpenSSH
|
||||
format (so private keys are interchangeable with `ssh-keygen`-produced
|
||||
keys); the same module backs both `.relicario/devices.json` entries and the
|
||||
server's pre-receive commit-verification hook.
|
||||
- **`tar_safe.rs`** — `safe_unpack_git_archive` + `DEFAULT_MAX_UNCOMPRESSED`
|
||||
(1 GiB). Hardened tar reader used by `backup::unpack_backup` for the
|
||||
bundled `.git/` directory: rejects `..` components, absolute paths, Windows
|
||||
drive prefixes, symlinks, hardlinks, and any entry whose declared size
|
||||
(or running total across all entries) exceeds the supplied cap.
|
||||
- **`recovery_qr.rs`** — `generate_recovery_qr` / `unwrap_recovery_qr` plus
|
||||
`recovery_qr_to_svg`. Produces a 109-byte XChaCha20-Poly1305 envelope
|
||||
around the 32-byte image_secret, keyed by Argon2id over a user-chosen
|
||||
recovery passphrase with the domain-separation prefix
|
||||
`b"relicario-recovery-v1\0"`. Parameters are pinned at module scope —
|
||||
changing them invalidates every printed QR — and both salt and nonce are
|
||||
freshly randomized per call so two QRs printed from the same inputs are
|
||||
different bytes.
|
||||
|
||||
## Invariants & contracts
|
||||
|
||||
@@ -386,11 +420,11 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
||||
`generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
|
||||
inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
|
||||
secret; production code is `OsRng` only.
|
||||
- **`ed25519-dalek` is a dependency placeholder.** Listed in
|
||||
`Cargo.toml:17` but unused in `src/`. It exists for the future
|
||||
device-key surface (`RelicarioError::DeviceKey` is the reserved variant,
|
||||
`error.rs:84-88`); device-key signing currently happens in
|
||||
`relicario-cli` instead.
|
||||
- **`ed25519-dalek` is consumed by `device.rs`.** Together with `ssh-key` (for
|
||||
OpenSSH wire encoding) it backs `generate_keypair`, `sign`, and `verify` —
|
||||
the same primitives the CLI uses to populate `.relicario/devices.json` and
|
||||
the server uses to verify pre-receive commit signatures. The corresponding
|
||||
error variant is `RelicarioError::DeviceKey`.
|
||||
|
||||
## Test architecture
|
||||
|
||||
@@ -512,3 +546,7 @@ round-trip, and the oversized-image-header rejection path.
|
||||
source in this crate** (`time.rs:6-8`). Tests that need determinism pass an
|
||||
explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they
|
||||
do not stub `now_unix`.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "relicario-core"
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2"
|
||||
|
||||
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.
|
||||
#[error("recovery QR: {0}")]
|
||||
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.
|
||||
|
||||
@@ -158,8 +158,8 @@ fn map_row(
|
||||
let totp = if totp_raw.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match decode_base32_totp(totp_raw) {
|
||||
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
||||
match crate::base32::decode_rfc4648_lenient(totp_raw) {
|
||||
Ok(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
||||
secret: Zeroizing::new(bytes),
|
||||
algorithm: crate::item_types::TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
@@ -196,25 +196,3 @@ fn map_row(
|
||||
(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::Totp(cfg) => {
|
||||
// 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)
|
||||
}
|
||||
_ => 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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -10,6 +10,9 @@ use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Steam Mobile Authenticator's 5-character output alphabet.
|
||||
/// 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";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
@@ -21,6 +24,14 @@ pub struct TotpCore {
|
||||
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)]
|
||||
pub struct TotpConfig {
|
||||
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
||||
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
||||
//! - [`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.
|
||||
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
||||
//! `ItemCore`/`ItemType` enums.
|
||||
@@ -46,6 +48,10 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
||||
pub mod ids;
|
||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||
|
||||
pub mod base32;
|
||||
|
||||
pub mod mime;
|
||||
|
||||
pub mod time;
|
||||
pub use time::{now_unix, MonthYear};
|
||||
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Current Unix timestamp in seconds.
|
||||
pub fn now_unix() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
@@ -15,7 +17,7 @@ pub struct 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) {
|
||||
return Err("month must be 1..=12");
|
||||
}
|
||||
@@ -24,6 +26,28 @@ impl MonthYear {
|
||||
}
|
||||
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)]
|
||||
@@ -60,4 +84,30 @@ mod tests {
|
||||
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "relicario-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Pre-receive Git hook for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
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()))
|
||||
}
|
||||
|
||||
// ── 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};
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -624,4 +650,24 @@ mod session_tests {
|
||||
// Should fail with a header validation error.
|
||||
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
|
||||
|
||||
@@ -161,11 +163,14 @@ master_key ────────►│ XChaCha20 │──────
|
||||
│ selected block: │
|
||||
│ │
|
||||
│ QIM embed bits │
|
||||
│ in positions │
|
||||
│ 4-15 (mid-freq) │
|
||||
│ in zig-zag │
|
||||
│ positions 6-17 │
|
||||
│ (mid-frequency) │
|
||||
│ │
|
||||
│ Repeat secret │
|
||||
│ 20+ times │
|
||||
│ MIN_COPIES (5) │
|
||||
│ to 50 times, │
|
||||
│ by capacity │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
@@ -181,6 +186,8 @@ master_key ────────►│ XChaCha20 │──────
|
||||
carries 256-bit secret)
|
||||
```
|
||||
|
||||
The redundancy count is chosen at embed time based on available DCT capacity: `num_copies = (total_blocks / BLOCKS_PER_COPY).min(50)`, with `BLOCKS_PER_COPY = 22` and a floor of `MIN_COPIES = 5` (`crates/relicario-core/src/imgsecret.rs:78,530-537`). Images that cannot fit at least 5 copies are rejected before embed. Majority voting across these copies at extract time requires ≥ 60 % confidence per bit.
|
||||
|
||||
## Extraction (with crop recovery)
|
||||
|
||||
```
|
||||
@@ -214,10 +221,12 @@ Input JPEG (possibly re-encoded or cropped)
|
||||
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
|
||||
│ version │ nonce │ ciphertext │ auth tag │
|
||||
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
|
||||
│ 0x01 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
|
||||
│ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
|
||||
└─────────┴────────────────────────┴──────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
`VERSION_BYTE = 0x02` (`crates/relicario-core/src/crypto.rs:59`). Blobs starting with any other byte are rejected with `UnsupportedFormatVersion { found, expected: 0x02 }`. The legacy `0x01` format from the pre-typed-items era is no longer supported.
|
||||
|
||||
## Crate Architecture
|
||||
|
||||
```
|
||||
@@ -267,3 +276,7 @@ Stolen device: ████░░░░░░░░░░░░░
|
||||
|
||||
Both factors compromised: game over (same as every password manager)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Next:** [FORMATS.md](FORMATS.md) — the byte-level wire formats.
|
||||
108
docs/FORMATS.md
Normal file
108
docs/FORMATS.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Relicario Wire Formats
|
||||
|
||||
> **Audience:** anyone implementing a compatible client or reading raw vault bytes. This doc owns the `.enc` blob layout, `params.json` / `salt` / `devices.json` / `revoked.json` shapes, the manifest JSON schema, the `.relbak` envelope, item-ID formats, and the settings JSON schema. **Does NOT own:** why these formats look this way (see [CRYPTO.md](CRYPTO.md)), threat model around them (see [SECURITY.md](SECURITY.md)), or Rust struct internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
|
||||
|
||||
> Quick-reference for the load-bearing binary and JSON formats. Check this file before touching serialization, versioning, or storage layout code. Full diagrams and invariants live in the per-crate `ARCHITECTURE.md` files.
|
||||
|
||||
## Encrypted blob (`.enc` files)
|
||||
|
||||
Every encrypted file — `manifest.enc`, `settings.enc`, `items/<id>.enc`, `attachments/<item-id>/<aid>.enc` — uses the layout produced by `relicario_core::crypto::encrypt` (`crypto.rs`):
|
||||
|
||||
```
|
||||
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
|
||||
│ version │ nonce │ ciphertext │ auth tag │
|
||||
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
|
||||
│ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
|
||||
└─────────┴────────────────────────┴──────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
- `VERSION_BYTE = 0x02` (`crypto.rs:59`). Any blob starting with `0x01` is rejected with `UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`.
|
||||
- Minimum valid blob length: 41 bytes (1 + 24 + 0 + 16).
|
||||
- Nonces are always fresh from `OsRng` — no caller-supplied nonces.
|
||||
- Full diagram: `docs/CRYPTO.md` § "Encrypted File Format".
|
||||
|
||||
## `.relicario/params.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 2,
|
||||
"aead": "xchacha20-poly1305",
|
||||
"salt_path": ".relicario/salt",
|
||||
"kdf": {
|
||||
"argon2_m": 65536,
|
||||
"argon2_t": 3,
|
||||
"argon2_p": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Parsed via `ParamsFile { kdf: KdfParams }` in `session.rs`. The `kdf` nesting is intentional — `format_version`, `aead`, and `salt_path` co-exist for forward-compat probing. Do not flatten. Production defaults: `m=65536` (64 MiB), `t=3`, `p=4`. Tests use `m=256, t=1, p=1`.
|
||||
|
||||
## `.relicario/salt`
|
||||
|
||||
32 raw bytes. Not secret. Generated once at vault init via `OsRng`. Feeds Argon2id as the KDF salt.
|
||||
|
||||
## Manifest (`manifest.enc`)
|
||||
|
||||
Decrypts to JSON matching the `Manifest` struct (`manifest.rs`).
|
||||
|
||||
- **Schema version:** `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`). v1 manifests (pre-typed-items) fail to parse and are not supported.
|
||||
- **`ManifestEntry` fields** (declared order in `manifest.rs:21-38`): `id`, `type`, `title`, `tags`, `favorite`, `group`, `icon_hint`, `modified`, `trashed_at`, `attachment_summaries`. The `type` field is `r#type: ItemType` in Rust but serializes as the bare JSON key `"type"` (no serde rename — `r#` is just the raw-identifier escape). `group`, `icon_hint`, and `trashed_at` are `#[serde(skip_serializing_if = "Option::is_none")]`; `tags`, `favorite`, and `attachment_summaries` use `#[serde(default)]`.
|
||||
- The manifest is rebuilt from scratch on every `upsert` — it can never drift from the source-of-truth item files.
|
||||
- Supports case-insensitive title/tag search without decrypting any item.
|
||||
|
||||
## `.relicario/devices.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "laptop", "public_key": "<hex-encoded ed25519 public key>" }
|
||||
]
|
||||
```
|
||||
|
||||
An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes accepted). Both `devices.json` and `revoked.json` must be empty for bootstrap mode to activate — a non-empty `revoked.json` alone forces strict verification.
|
||||
|
||||
## `.relicario/revoked.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "old-laptop", "public_key": "<hex>", "revoked_at": 1746000000 }
|
||||
]
|
||||
```
|
||||
|
||||
Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time).
|
||||
|
||||
## Item IDs and Field IDs
|
||||
|
||||
| Kind | Length | Entropy | Source |
|
||||
|---|---|---|---|
|
||||
| `ItemId` | 16 hex chars | 64 bits | `OsRng` |
|
||||
| `FieldId` | 16 hex chars | 64 bits | `OsRng` |
|
||||
| `AttachmentId` | 32 hex chars | 128 bits | first 16 bytes (32 hex chars) of `SHA-256` over the plaintext |
|
||||
|
||||
`AttachmentId` is content-addressed — identical plaintexts deduplicate in git automatically. The 128-bit truncation (`ids.rs:59-69`) was widened from 64 bits per audit I2/B4 to put birthday-collision risk out of reach.
|
||||
|
||||
## `.relbak` backup format
|
||||
|
||||
A zstd-compressed tar archive containing a bare git clone of the vault. Designed for `relicario backup export/restore`.
|
||||
|
||||
Full spec: `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md`.
|
||||
|
||||
## `ItemCore` JSON (internal)
|
||||
|
||||
`ItemCore` uses `#[serde(tag = "type")]` — the outer JSON object gets a `"type"` discriminator key. No `*Core` struct may have a field named `"type"` (use `"kind"` instead — see `CardKind`, `TotpKind`).
|
||||
|
||||
Full item type inventory: `crates/relicario-core/ARCHITECTURE.md` § "Module map".
|
||||
|
||||
## KDF input construction
|
||||
|
||||
The password fed to Argon2id is length-prefixed to prevent extension attacks:
|
||||
|
||||
```
|
||||
u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || image_secret
|
||||
```
|
||||
|
||||
NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [SECURITY.md](SECURITY.md) — the threat model.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Relicario Security Model
|
||||
|
||||
> **Audience:** auditors and curious users. This doc owns the threat model, attacker-scenarios table, device-authentication model, env-var trust surface, and known limitations. **Does NOT own:** crypto primitive details (see [CRYPTO.md](CRYPTO.md)), wire formats (see [FORMATS.md](FORMATS.md)), or implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) and [../crates/relicario-cli/ARCHITECTURE.md](../crates/relicario-cli/ARCHITECTURE.md)).
|
||||
|
||||
## Cryptographic Protection
|
||||
|
||||
Relicario uses two-factor vault decryption:
|
||||
@@ -102,3 +104,7 @@ standard `--release` profile).
|
||||
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
||||
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
|
||||
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
|
||||
|
||||
---
|
||||
|
||||
**Next:** [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) — implementation, starting with the platform-agnostic core.
|
||||
|
||||
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,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.
|
||||
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.
|
||||
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
@@ -0,0 +1,466 @@
|
||||
# Vault-tab management surfaces revamp
|
||||
|
||||
**Date:** 2026-05-23
|
||||
**Status:** Spec, awaiting review
|
||||
**Surface:** Browser extension management panes — `extension/src/popup/components/` (shared between popup and vault tab)
|
||||
|
||||
## Problem
|
||||
|
||||
Four "management" surfaces in the extension — **Settings**, **Devices**, **Trash**, and **field history** — all shipped in the 1C-β₂ / device-auth waves but in the *pre-fullscreen-redesign* visual language. They read as popup-derived forms stretched across the vault tab, with inconsistent typography, no glyph buttons, no focus rings, and no visual section grouping. Several functional gaps remain alongside the visual debt:
|
||||
|
||||
- **Settings**: per-device session-timeout config UI was specced in the vault-tab design (2026-04-27) but never built; the only way to change session behavior today is to edit `chrome.storage.local` directly.
|
||||
- **Devices**: revocation works via a plain text "revoke" button + browser `confirm()` dialog — functional but inconsistent with the rest of the extension's UX. Device entries don't expose the SHA256 fingerprint (used for verifying against the server-side `devices.json`) or the `added_by` field that's already in `DeviceEntry`.
|
||||
- **Trash**: per-item purge countdown isn't shown — users see "trashed N days ago" but have to mentally add the retention window to figure out when it'll be gone.
|
||||
- **History**: the per-item field-history viewer (`field-history.ts`) is only reachable from an item detail page; there's no entry point to discover *which* items have history.
|
||||
|
||||
This spec applies the fullscreen visual-language tokens to all four panes and closes the gaps above. It deliberately stays small and ships in the v0.5.x train, in the current `vault.ts` shell — Phase 3 shell rearchitecture is out of scope.
|
||||
|
||||
## Goals
|
||||
|
||||
- All four management panes adopt the fullscreen visual language: glyph buttons, focus ring, uppercase section headers with 1px bottom rule, accent tokens, required-field pill where applicable.
|
||||
- Close the four functional gaps above (session timeout UI, revoke button surfacing, fingerprint + added-by display, purge countdown, history index).
|
||||
- Add **one new pane** — "items with history" index — reachable from a new `◷ history` slot in the sidebar bottom-nav.
|
||||
- Zero core or wasm changes; zero data-model changes; zero new schema versions.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Phase 3 shell rearchitecture** (three-pane layout, `shell/three-pane.ts`, `keymap.ts`) — separate effort, separate spec.
|
||||
- **Phase 4 command palette** — deferred to its own brainstorm round.
|
||||
- **Item-level snapshot history** — option B/C from brainstorm; this spec uses option A (aggregate existing `field_history` per item; no new core storage).
|
||||
- **Settings as a hub with sub-tabs** — would introduce sub-tab pattern not used elsewhere; defer.
|
||||
- **Trash polish**: hover-preview, multi-select bulk-restore — defer.
|
||||
- **Devices polish**: rotate-key flow, "last seen" detail (would need new data) — defer.
|
||||
- **History polish**: diff view between historical values — defer.
|
||||
|
||||
## Scope summary
|
||||
|
||||
| Surface | Files touched | New? |
|
||||
|---|---|---|
|
||||
| Settings | `popup/components/settings-vault.ts`, `vault.css`/`popup.css` | modify |
|
||||
| Devices | `popup/components/devices.ts`, new `shared/ssh-fingerprint.ts` | modify + 1 new util |
|
||||
| Trash | `popup/components/trash.ts` | modify |
|
||||
| History — index | `popup/components/item-history-index.ts` | **NEW** |
|
||||
| History — per-item | `popup/components/field-history.ts` | polish only (no rename) |
|
||||
| Glyph constants | `shared/glyphs.ts` | depends-on-or-creates |
|
||||
| Time helper | `shared/relative-time.ts` | **NEW** (extracted from 3 call sites) |
|
||||
| Routing | `vault/vault.ts` | add `#history` + `#history/<itemId>` routes |
|
||||
| Nav | sidebar bottom-nav | grows 3 → 4 (`▦ trash · ⌬ devices · ⚙ settings · ◷ history`) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component map
|
||||
|
||||
```
|
||||
extension/src/
|
||||
├── shared/
|
||||
│ ├── glyphs.ts ← depends on (or creates if absent):
|
||||
│ │ GLYPH_TRASH ▦, GLYPH_DEVICES ⌬,
|
||||
│ │ GLYPH_SETTINGS ⚙, GLYPH_LOCK ⏻,
|
||||
│ │ GLYPH_HISTORY ◷, GLYPH_REVOKE ⊘,
|
||||
│ │ GLYPH_RESTORE ⤺, GLYPH_REVEAL ⊙,
|
||||
│ │ GLYPH_COPY ⧉
|
||||
│ └── relative-time.ts ← NEW (small util — inlined in 3 places today)
|
||||
├── popup/components/
|
||||
│ ├── settings-vault.ts ← rewrite layout; add session-timeout row
|
||||
│ ├── devices.ts ← add fingerprint, "added by"; surface revoke button
|
||||
│ ├── trash.ts ← add per-item purge countdown
|
||||
│ ├── field-history.ts ← visual polish only (filename kept)
|
||||
│ └── item-history-index.ts ← NEW: "items with history" index
|
||||
└── vault/
|
||||
├── vault.ts ← add #history routes + bottom-nav slot
|
||||
├── vault.css ← four shared utility classes (below)
|
||||
└── popup.css ← same classes (shared components render in both)
|
||||
```
|
||||
|
||||
### SW message protocol — 99% reuse
|
||||
|
||||
| Capability | Message | Status |
|
||||
|---|---|---|
|
||||
| Read/write vault settings | `get_vault_settings` / `update_vault_settings` | exists |
|
||||
| Read/write session timeout (per-device) | `get_session_config` / `update_session_config` | exists |
|
||||
| List active + revoked devices | `list_devices` / `list_revoked` | exists |
|
||||
| Register / revoke device | `register_this_device` / `revoke_device` | exists |
|
||||
| List trashed, restore, purge | `list_trashed` / `restore_item` / `purge_item` / `purge_all_trash` | exists |
|
||||
| Per-item field history | `get_field_history` | exists (reused for index + per-item) |
|
||||
|
||||
**No SW shape changes.** Fingerprint is computed client-side in `devices.ts` via `crypto.subtle.digest('SHA-256', …)` against the base64-decoded ed25519 key blob from `DeviceEntry.public_key`. Result is formatted as `SHA256:<base64-no-pad>` to match the SSH convention (and what `relicario device list` prints from `core::device::fingerprint()`). Pure extension change — no message round-trip, no WASM export, no Rust change.
|
||||
|
||||
### Shared CSS utility classes
|
||||
|
||||
Defined in `vault.css` and `popup.css` (shared because components render in both contexts):
|
||||
|
||||
```css
|
||||
.section-header {
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.glyph-btn {
|
||||
min-width: 28px;
|
||||
font-family: ui-monospace, monospace;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.glyph-btn:hover { color: var(--text); background: var(--bg-input); }
|
||||
.glyph-btn:focus-visible { box-shadow: var(--focus-ring); outline: none; }
|
||||
.glyph-btn[data-danger]:hover { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
.kv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.kv-row > .k { color: var(--text-muted); }
|
||||
.kv-row > .v { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.fingerprint {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
word-break: break-all; /* wraps to two lines in popup (~360px) */
|
||||
}
|
||||
```
|
||||
|
||||
### Visual language reference
|
||||
|
||||
All tokens come from the existing fullscreen UX redesign spec (`2026-04-30-relicario-fullscreen-ux-redesign-design.md`, "Visual language" section). No new tokens introduced. New glyph constants added to `shared/glyphs.ts` if not already present: `GLYPH_HISTORY ◷`, `GLYPH_REVOKE ⊘`, `GLYPH_RESTORE ⤺`, `GLYPH_REVEAL ⊙`, `GLYPH_COPY ⧉`.
|
||||
|
||||
---
|
||||
|
||||
## A. Settings pane
|
||||
|
||||
Two-tier section grouping makes the storage distinction explicit: **VAULT SETTINGS · synced** lives in the encrypted vault (replicated via git), **THIS DEVICE · local** lives in `chrome.storage.local` (per-device, not synced). **ACTIONS** is destructive/expensive operations.
|
||||
|
||||
```
|
||||
◀ settings
|
||||
unsaved · ⌘+S to save no changes
|
||||
|
||||
|
||||
VAULT SETTINGS · synced
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
│ RETENTION │ │ GENERATOR │
|
||||
│ trash [30 days ▾] │ │ length 24 │
|
||||
│ history [last 5 ▾] │ │ words 4 │
|
||||
│ │ │ [ configure defaults ↻ ] │
|
||||
└──────────────────────────┘ └──────────────────────────┘
|
||||
|
||||
┌──────────────────────────┐
|
||||
│ ATTACHMENTS │
|
||||
│ max size [25 MB ▾] │
|
||||
└──────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AUTOFILL ORIGINS │
|
||||
│ github.com acknowledged 2d ago ⊘ │
|
||||
│ gitlab.adlee.work acknowledged 5d ago ⊘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
THIS DEVICE · local
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
┌──────────────────────────┐
|
||||
│ SESSION │
|
||||
│ ○ lock every time │
|
||||
│ ● after inactivity │
|
||||
│ [15 min ▾] │
|
||||
└──────────────────────────┘
|
||||
|
||||
|
||||
ACTIONS
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
[ backup & restore ] [ import from… ]
|
||||
```
|
||||
|
||||
### Decisions
|
||||
|
||||
- **Two-tier grouping** with `· synced` / `· local` muted suffixes makes storage scope unambiguous without being preachy.
|
||||
- **Two-column where fields are small** (Retention ↔ Generator; Attachments standalone in row 2). Collapses to single-column under 720px viewport — same rule the login form uses.
|
||||
- **Session timeout** wires the existing `get_session_config` / `update_session_config` SW messages to a radio (`every_time` / `inactivity`) + minutes dropdown (5/15/30/60). Already-validated config shape from the vault-tab spec.
|
||||
- **Form header** reuses the "unsaved · ⌘+S to save" / "no changes" subtitle from the form-layout spec — gives Settings the same dirty-state feedback as item edits.
|
||||
- **Generator section** keeps the current "configure defaults" button opening the popover from Phase 2A — no inline expansion.
|
||||
- **Actions** uses text-labelled buttons (not glyph buttons) since they open dedicated panes; text is clearer than icons for navigation-style actions.
|
||||
|
||||
---
|
||||
|
||||
## B. Devices pane
|
||||
|
||||
Single column (this is a list, not a form). Three-line per-entry rhythm: name (+ `← you` marker or `⊘` revoke glyph), full SHA256 fingerprint, then `added X ago · by Y`.
|
||||
|
||||
```
|
||||
◀ devices
|
||||
|
||||
|
||||
ACTIVE · 3
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
⌬ Aaron's laptop ← you
|
||||
SHA256:8f3a:c7d2:1e44:9b08:6f55:a201:de9c:4477
|
||||
added 2 months ago · by Aaron
|
||||
|
||||
⌬ Aaron's phone ⊘
|
||||
SHA256:9c11:e4f8:2a91:db32:7c0e:51bb:e8a4:1f6d
|
||||
added 3 weeks ago · by Aaron's laptop
|
||||
|
||||
⌬ work-laptop ⊘
|
||||
SHA256:b277:35aa:c1e0:8f44:62b9:0d3e:7c1f:5d92
|
||||
added 8 days ago · by Aaron's laptop
|
||||
|
||||
|
||||
REVOKED · 1
|
||||
─────────────────────────────────────────────────────────────
|
||||
▸ show 1 revoked device
|
||||
```
|
||||
|
||||
### Revoke confirmation — inline two-step
|
||||
|
||||
Clicking `⊘` expands a confirmation panel in place (no modal):
|
||||
|
||||
```
|
||||
⌬ Aaron's phone
|
||||
SHA256:9c11:e4f8:2a91:db32:…
|
||||
added 3 weeks ago · by Aaron's laptop
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Revoke this device? It won't be able to sign │
|
||||
│ commits or push changes after revocation. │
|
||||
│ │
|
||||
│ [ cancel ] [ revoke ] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Unregistered state — top banner
|
||||
|
||||
```
|
||||
◀ devices
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ This device isn't registered. │
|
||||
│ Registering generates an ed25519 keypair and adds the │
|
||||
│ public key to .relicario/devices.json on the remote. │
|
||||
│ │
|
||||
│ [ register this device ] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
ACTIVE · 2
|
||||
─────────────────────────────────────────────────────────────
|
||||
…
|
||||
```
|
||||
|
||||
### Decisions
|
||||
|
||||
- **Full fingerprint shown** (no truncation): verifiability against `.relicario/devices.json` is the whole point of displaying it. Popup-width wrapping handled by `.fingerprint { word-break: break-all }` — wraps to two lines under ~360px.
|
||||
- **`by Y` semantics**: `DeviceEntry.added_by` — the name of the device that signed the registration commit. Already in the data model, just unsurfaced today.
|
||||
- **Inline two-step revoke** keeps the lightness of the rest of the extension's UX; modal would feel disproportionate. The current device gets no revoke button (the CLI keeps the "revoke self" escape hatch since that needs different post-revoke handling).
|
||||
- **Revoked section** collapsed by default with count; expanded entries get the same three-line rhythm minus the revoke button, plus `revoked X ago · by Y`.
|
||||
- **Unregistered banner**: fleshed-out copy explaining what registration *does* (current one-liner feels mysterious per memory of past confusion). Same flow underneath — click → modal with device-name input → `register_this_device` SW message.
|
||||
|
||||
---
|
||||
|
||||
## C. Trash pane
|
||||
|
||||
```
|
||||
◀ trash
|
||||
|
||||
3 items · oldest purges in 22 days
|
||||
|
||||
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
🔑 GitHub login ⤺
|
||||
trashed 8 days ago · purges in 22 days
|
||||
|
||||
📝 Recovery note ⤺
|
||||
trashed 12 days ago · purges in 18 days
|
||||
|
||||
🔑 old-aws-root ⤺
|
||||
trashed 18 days ago · purges in 12 days
|
||||
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
[ empty trash ]
|
||||
```
|
||||
|
||||
### Decisions
|
||||
|
||||
- **Two-line per-entry**: type icon + name + `⤺` restore glyph; muted second line `trashed X ago · purges in Y days`.
|
||||
- **Per-item purge countdown** computed client-side from `trashed_at + retention_seconds - now` and formatted via `shared/relative-time.ts`. Updates on pane render (no live timer — sub-day precision unnecessary).
|
||||
- **Header summary stays** ("3 items · oldest purges in 22 days") — useful at-a-glance.
|
||||
- **Destructive empty button anchored bottom-right** — separated from per-row restore buttons to reduce mis-clicks. Confirmation flow unchanged from today.
|
||||
- **Sort**: trashed-date descending (newest first). Defer "sort by purge urgency" toggle — not strongly requested and adds toolbar real estate.
|
||||
- **Type icons** stay as today (emoji per item type) — the global glyph treatment is for *action* buttons; type icons are content-classification and read better as the existing emoji set.
|
||||
|
||||
---
|
||||
|
||||
## D. History — index pane (NEW)
|
||||
|
||||
Reachable from a new `◷ history` bottom-nav slot. Sorted by most-recent-change descending.
|
||||
|
||||
```
|
||||
◀ history
|
||||
|
||||
5 items have field history
|
||||
|
||||
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
🔑 GitHub login
|
||||
3 changes · last 2 days ago
|
||||
|
||||
🔑 AWS prod
|
||||
1 change · last 2 weeks ago
|
||||
|
||||
📝 Recovery note
|
||||
2 changes · last 3 weeks ago
|
||||
|
||||
🔑 Cloudflare
|
||||
1 change · last 1 month ago
|
||||
|
||||
🌐 personal-email
|
||||
4 changes · last 2 months ago
|
||||
```
|
||||
|
||||
### Decisions
|
||||
|
||||
- **Implementation**: iterate manifest entries, fetch + decrypt each item (already cached in session state where decrypted), inspect `field_history` map; emit entries that have ≥1 history-tracked field with non-empty history. Count = sum of `field_history[*].length`. Last-changed = max `replaced_at` across all history entries.
|
||||
- **Click row** → routes to `#history/<itemId>` (per-item view below).
|
||||
- **Empty state** when no items have history: *"No field history yet. Edits to passwords, TOTP secrets, and concealed fields will appear here."*
|
||||
- **No excerpts** in the index — keeping it lean; the per-item view is one click away.
|
||||
|
||||
---
|
||||
|
||||
## E. History — per-item view (existing, polished)
|
||||
|
||||
Existing component (`field-history.ts`, filename kept). Visual polish only — apply the section-header rule, glyph buttons, focus ring, accent tokens. **No structural changes to content layout.**
|
||||
|
||||
```
|
||||
◀ history · GitHub login
|
||||
|
||||
|
||||
PASSWORD · 3 entries
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
current ●●●●●●●●●●●●●●●●●●●●●●●● ⊙ ⧉
|
||||
set 2 days ago
|
||||
───
|
||||
previous ●●●●●●●●●●●●●●●●●●● ⊙ ⧉
|
||||
3 weeks ago
|
||||
───
|
||||
previous ●●●●●●●●●●●●●●●● ⊙ ⧉
|
||||
2 months ago
|
||||
|
||||
|
||||
TOTP_SECRET · 1 entry
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
current ●●●●●●●●●●●●●●●●●●●●●●●● ⊙ ⧉
|
||||
set 1 month ago
|
||||
───
|
||||
previous ●●●●●●●●●●●●●●●●●●● ⊙ ⧉
|
||||
(created · 3 months ago)
|
||||
```
|
||||
|
||||
### Decisions
|
||||
|
||||
- **Filename kept** as `field-history.ts` per user feedback during brainstorm.
|
||||
- **Routing**: continues to be reachable from item-detail "view history" button (`#history/<itemId>`). Now also reachable by drilling into the history index pane.
|
||||
- **Reveal/copy glyphs** updated to `⊙ ⧉` constants from `shared/glyphs.ts`.
|
||||
- **Per-field uppercase header** + 1px rule applied — matches the new visual rhythm.
|
||||
- **Values stay concealed by default** (existing behavior). Reveal toggle and copy button per entry. Values held in component-local `valueStore` map (not DOM attributes) — existing security pattern preserved.
|
||||
|
||||
---
|
||||
|
||||
## Routing & sidebar nav changes
|
||||
|
||||
### `vault.ts` `VaultView` union changes
|
||||
|
||||
```ts
|
||||
type VaultView =
|
||||
| 'list' | 'detail' | 'add' | 'edit'
|
||||
| 'trash' | 'devices' | 'settings' | 'settings-vault'
|
||||
| 'field-history' // existing — per-item view (internal dispatch key kept)
|
||||
| 'history' // NEW — index pane only
|
||||
| 'backup' | 'import'
|
||||
```
|
||||
|
||||
The user-facing hash changes (`#history` is the new entry point, `#history/<id>` is the per-item view), but the internal dispatch keeps `'field-history'` for the per-item view to minimize the diff to working code. Normalization happens in `parseHash`:
|
||||
|
||||
- `#history` → `{ view: 'history' }` → index pane (`item-history-index.ts`)
|
||||
- `#history/<itemId>` → `{ view: 'field-history', id: <itemId> }` → per-item view (`field-history.ts`)
|
||||
- `#field-history/<itemId>` → rewritten to `#history/<itemId>` in the address bar, then resolved as above (one release of backward compat for any bookmarked URLs)
|
||||
|
||||
### Sidebar bottom-nav
|
||||
|
||||
```
|
||||
▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock
|
||||
```
|
||||
|
||||
Five glyph buttons in a row at ~240px sidebar width: comfortable at 1ch each + padding. Verified.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
UI work — verification is mostly manual smoke:
|
||||
|
||||
- Each pane loads, renders, round-trips edits
|
||||
- Settings round-trip: change retention/session/attachments → reload → values persist; session-timeout actually fires lock after configured minutes
|
||||
- Devices: fingerprint string matches `relicario device list` CLI output; revoke happy path + cancel; unregistered banner → register flow → confirm
|
||||
- Trash: per-item purge countdown updates correctly; empty trash → confirms then purges
|
||||
- History: index sorted by recency; click drills in; empty state when no history; both `#history/<id>` and the item-detail entry point reach the same view
|
||||
- Cross-context: each shared component renders correctly in both popup (~360px) and vault tab (full)
|
||||
|
||||
**Unit tests** — only where logic warrants:
|
||||
- `shared/relative-time.ts` — table-driven test of fixture timestamps → strings
|
||||
- Purge-countdown formatting
|
||||
|
||||
No new test infrastructure. Extension currently has no snapshot tests per inventory; not adding any here.
|
||||
|
||||
---
|
||||
|
||||
## Rollout
|
||||
|
||||
- Single PR, v0.5.x train.
|
||||
- No data-model migration, no schema change, no core or wasm changes.
|
||||
- Purely `extension/src/`: one new shared util, one new pane file, four modified components, CSS additions, two routing additions.
|
||||
- Doc updates: `STATUS.md` move-to-recent on land; `extension/ARCHITECTURE.md` note the new `◷ history` route + 4th sidebar slot.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Bottom-nav crowding (4 + lock = 5 items at ~240px sidebar) | Glyphs are 1ch each; ample room verified, but confirm at narrowest viewport during smoke |
|
||||
| Fingerprint length in popup context (~330px monospace) | CSS `word-break: break-all` on `.fingerprint`; no truncation |
|
||||
| `shared/glyphs.ts` may not exist yet | Spec creates it if absent (called out in §1) — depends-on-or-creates |
|
||||
| Decrypting all items for the history index pane | Most items are already cached after a session warm-up; cost is per-pane-load not per-event; acceptable for family-vault item counts |
|
||||
| Inline revoke confirmation could be missed (no modal blocker) | Two-step pattern matches other extension confirmations (delete item, empty trash); copy is explicit about consequence |
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (deferred to future rounds)
|
||||
|
||||
- Phase 3 shell rearchitecture (three-pane layout, command palette, drag-resize panes)
|
||||
- Phase 4 command palette
|
||||
- Item-level snapshot history (option B/C)
|
||||
- Settings-as-hub with sub-tabs
|
||||
- Trash multi-select / bulk-restore / hover preview
|
||||
- Devices rotate-key flow / "last seen" detail
|
||||
- History diff view between adjacent values
|
||||
- Whole-revamp animations or transitions
|
||||
@@ -0,0 +1,245 @@
|
||||
# Doc Structure Redesign — Design
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Proposed
|
||||
**Source:** Drift audit run on this same date (three parallel agents over the living docs) + follow-up brainstorm with the user.
|
||||
**Effort estimate:** S (one focused afternoon — renames + headers + link-fixes, no content rewrites).
|
||||
|
||||
## Summary
|
||||
|
||||
The living docs split into roughly thirteen files spread across the repo root, `docs/`, `crates/*`, and `extension/`. Three of them are called `ARCHITECTURE.md` and overlap in scope, which is exactly where the drift audit clustered (top-level called `0x01` while code shipped `0x02`, `FORMATS.md` listed 16-hex AttachmentIds while code used 32, per-crate maps missed five public modules and several CLI commands). This design keeps the file count roughly the same but **renames docs by topic, pins each doc to an explicit scope, and chains them into a single reading order**. A contributor (or future-you after a long break) lands on `README`, gets walked through `DESIGN → docs/CRYPTO → docs/FORMATS → docs/SECURITY → crates/*/ARCHITECTURE.md → extension/ARCHITECTURE.md`, never sees two files with the same name, and never has to guess which doc owns a given fact.
|
||||
|
||||
The drift fixes themselves landed in three commits earlier today (`210232d`, `cf7478d`, `fa659eb`). This redesign attacks the *structural* causes of the drift so the next audit has less to find.
|
||||
|
||||
## Findings addressed
|
||||
|
||||
The drift audit produced a punch list across three themes. The structural causes this redesign attacks are:
|
||||
|
||||
- **Three files named `ARCHITECTURE.md` at three levels with overlapping scope.** Top-level vs `docs/ARCHITECTURE.md` overlap drove the `0x01`-vs-`0x02` divergence and the `MIN_COPIES` confusion. Renaming the top-level to `DESIGN.md` and the `docs/` one to `docs/CRYPTO.md` eliminates the name collision and makes each doc's topic obvious from its filename.
|
||||
- **No scope boundaries between docs.** When the wire-format byte changed in code, there was no rule saying "the version-byte diagram lives in `docs/CRYPTO.md`, not in `FORMATS.md` or `DESIGN.md`," so the diagrams in two docs drifted apart. Adding explicit scope headers to each tour doc makes "where does this fact go?" unambiguous.
|
||||
- **No reading order signposts.** A cold reader couldn't tell whether to start at README, top-level ARCHITECTURE, or `docs/ARCHITECTURE.md`. "Next:" footers on every tour doc make a single canonical path.
|
||||
- **`FORMATS.md` sitting at the repo root while every other reference doc sits in `docs/`.** Asymmetry adds cognitive load. Moving it to `docs/FORMATS.md` aligns with the rest.
|
||||
- **`CLAUDE.md`'s "Living docs — update discipline" table is the only place the scope-rules are written.** It lists *when* to update each doc but not *what each doc owns vs does not own*. The new scope headers act as on-doc enforcement; the CLAUDE.md table is updated to point at the new filenames and adds three new discipline rules.
|
||||
|
||||
Out of scope (intentionally): STATUS.md drift habit (a behavioural problem, not structural); per-crate `main.rs:NNNN` line-number citations going stale when handlers move (a habit nudge — cite by function name, not line — but cross-cutting and worth its own pass).
|
||||
|
||||
## Goals
|
||||
|
||||
- A cold reader can flow from `README → DESIGN → docs/CRYPTO → docs/FORMATS → docs/SECURITY → crates/*/ARCHITECTURE.md → extension/ARCHITECTURE.md` without ping-ponging or guessing which doc owns what.
|
||||
- Every tour doc declares its scope in a 1-2 sentence header at the top and points at the next doc in a single-line footer at the bottom.
|
||||
- The drift surface shrinks: no two docs claim to own the same fact.
|
||||
- Migration is mechanical (renames + header additions + link fixes); no content is rewritten. The drift audit already cleaned the content.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Renaming per-crate or extension `ARCHITECTURE.md` files. Their nesting (`crates/X/`, `extension/`) already disambiguates them.
|
||||
- Adding new ARCHITECTURE.md files for `relicario-server` or `relicario-wasm`. Both crates are small enough that a per-crate doc would be more maintenance than help. Add later if either grows.
|
||||
- Touching `STATUS.md`, `ROADMAP.md`, `CHANGELOG.md`. Their roles are well-defined and the audit found no structural issue there.
|
||||
- Touching `docs/superpowers/specs/` or `docs/superpowers/plans/`. Intentionally accumulating.
|
||||
- Adding `CONTRIBUTING.md`. The "me + Claude, contributor-ready" audience choice means we keep the docs welcoming but don't bolt on contributor-onboarding pages we don't need yet.
|
||||
|
||||
## Target structure
|
||||
|
||||
```
|
||||
README.md Front door (already great). Trim its mid-section
|
||||
"Architecture" stub to a one-paragraph pointer at
|
||||
DESIGN.md. Pitch + security model + quick-start +
|
||||
reference image + recovery + roadmap teaser stay.
|
||||
|
||||
DESIGN.md NEW NAME (replaces top-level ARCHITECTURE.md).
|
||||
The system tour: four codebases, contracts between
|
||||
them, secrets map, build matrix, global code-map index.
|
||||
|
||||
docs/
|
||||
├── CRYPTO.md NEW NAME (renamed from docs/ARCHITECTURE.md).
|
||||
│ Crypto pipeline + vault flows + DCT embedding +
|
||||
│ high-level encrypted-file-format diagram.
|
||||
│
|
||||
├── FORMATS.md MOVED from repo root.
|
||||
│ Wire formats: .enc blob layout, params.json,
|
||||
│ devices.json, manifest schema, .relbak envelope,
|
||||
│ ID formats, settings JSON schema.
|
||||
│
|
||||
└── SECURITY.md UNCHANGED LOCATION.
|
||||
Threat model, attacker scenarios, device auth,
|
||||
env-var trust surface.
|
||||
|
||||
crates/
|
||||
├── relicario-core/ARCHITECTURE.md UNCHANGED (nesting disambiguates).
|
||||
├── relicario-cli/ARCHITECTURE.md UNCHANGED.
|
||||
├── relicario-server/ No doc — too small.
|
||||
└── relicario-wasm/ No doc — too small.
|
||||
|
||||
extension/ARCHITECTURE.md UNCHANGED.
|
||||
|
||||
(unchanged: STATUS / ROADMAP / CHANGELOG / CLAUDE / LICENSE / docs/superpowers/)
|
||||
```
|
||||
|
||||
**Reading order:**
|
||||
|
||||
```
|
||||
README → DESIGN → docs/CRYPTO → docs/FORMATS → docs/SECURITY
|
||||
→ crates/relicario-core/ARCHITECTURE.md
|
||||
→ crates/relicario-cli/ARCHITECTURE.md
|
||||
→ extension/ARCHITECTURE.md
|
||||
```
|
||||
|
||||
Realized by two conventions on every tour doc:
|
||||
|
||||
1. **Scope header (top, 1-2 sentences):** *"This doc owns X. See Y for Z."*
|
||||
2. **"Next:" footer:** a one-line pointer to the next doc in the tour.
|
||||
|
||||
## Per-file scope definitions
|
||||
|
||||
The exact scope headers + "Next:" footers to be pasted at the top and bottom of each tour doc.
|
||||
|
||||
### `README.md`
|
||||
|
||||
> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md).
|
||||
|
||||
Existing-content delta: the current "Architecture" section gets trimmed to one paragraph pointing at `DESIGN.md`. Quick start / Reference image / Recovery / Roadmap sections stay.
|
||||
|
||||
Footer: `Next: [DESIGN.md](DESIGN.md) — the system tour.`
|
||||
|
||||
### `DESIGN.md` *(new name; replaces top-level `ARCHITECTURE.md`)*
|
||||
|
||||
> **Audience:** anyone wanting to understand the system at the cross-codebase level. This doc owns the four-codebase map, inter-codebase contracts, the secrets map (what secret lives where), the build matrix, and the global code-map index. **Does NOT own:** crypto pipeline details (see `docs/CRYPTO.md`), wire formats (see `docs/FORMATS.md`), threat model (see `docs/SECURITY.md`), per-crate module maps (see `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md`).
|
||||
|
||||
Footer: `Next: [docs/CRYPTO.md](docs/CRYPTO.md) — the crypto pipeline that backs this design.`
|
||||
|
||||
### `docs/CRYPTO.md` *(new name; renamed from `docs/ARCHITECTURE.md`)*
|
||||
|
||||
> **Audience:** anyone evaluating or auditing the crypto. This doc owns Argon2id parameters and rationale, XChaCha20-Poly1305 rationale, vault creation/unlock flow diagrams, DCT-steganography embed and extract flows, and the high-level encrypted-file-format diagram. **Does NOT own:** byte-level schemas or JSON shapes (see `docs/FORMATS.md`), attacker scenarios (see `docs/SECURITY.md`), or per-module crypto implementation (see `crates/relicario-core/ARCHITECTURE.md`).
|
||||
|
||||
Footer: `Next: [FORMATS.md](FORMATS.md) — the byte-level wire formats.`
|
||||
|
||||
### `docs/FORMATS.md` *(moved from repo root)*
|
||||
|
||||
> **Audience:** anyone implementing a compatible client or reading raw vault bytes. This doc owns the `.enc` blob layout, `params.json` / `salt` / `devices.json` / `revoked.json` shapes, the manifest JSON schema, the `.relbak` envelope, item-ID formats, and the settings JSON schema. **Does NOT own:** why these formats look this way (see `docs/CRYPTO.md`), threat model around them (see `docs/SECURITY.md`), or Rust struct internals (see `crates/relicario-core/ARCHITECTURE.md`).
|
||||
|
||||
Footer: `Next: [SECURITY.md](SECURITY.md) — the threat model.`
|
||||
|
||||
### `docs/SECURITY.md` *(unchanged location)*
|
||||
|
||||
> **Audience:** auditors and curious users. This doc owns the threat model, attacker-scenarios table, device-authentication model, env-var trust surface, and known limitations. **Does NOT own:** crypto primitive details (see `docs/CRYPTO.md`), wire formats (see `docs/FORMATS.md`), or implementation (see `crates/*/ARCHITECTURE.md`).
|
||||
|
||||
Footer: `Next: [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) — implementation, starting with the platform-agnostic core.`
|
||||
|
||||
### `crates/relicario-core/ARCHITECTURE.md`
|
||||
|
||||
> **Audience:** contributors editing or extending `relicario-core`. This doc owns the module map for this crate, module-level invariants (e.g., no filesystem, no network), key flows at the module level, and the crate's test architecture. **Does NOT own:** crypto primitives or threat model (see `docs/CRYPTO.md`, `docs/SECURITY.md`), wire formats (see `docs/FORMATS.md`).
|
||||
|
||||
Footer: `Next: [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.`
|
||||
|
||||
### `crates/relicario-cli/ARCHITECTURE.md`
|
||||
|
||||
> **Audience:** contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. **Does NOT own:** crypto, wire formats, or threat model (see `docs/`).
|
||||
|
||||
Footer: `Next: [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.`
|
||||
|
||||
### `extension/ARCHITECTURE.md`
|
||||
|
||||
> **Audience:** contributors editing the browser extension. This doc owns the bundle structure (popup, vault tab, background SW, content scripts), the SW ↔ popup message contract, the component / pane architecture, routing, and the build pipeline. **Does NOT own:** WASM crypto internals (see `crates/relicario-core/ARCHITECTURE.md`), wire formats (see `docs/FORMATS.md`), or threat model (see `docs/SECURITY.md`).
|
||||
|
||||
Footer: `End of tour. For roadmap and in-flight work see [../STATUS.md](../STATUS.md) and [../ROADMAP.md](../ROADMAP.md).`
|
||||
|
||||
## Migration
|
||||
|
||||
Five sequential commits. Each is mechanical; no content is rewritten.
|
||||
|
||||
### Commit 1 — Renames
|
||||
|
||||
```bash
|
||||
git mv ARCHITECTURE.md DESIGN.md
|
||||
git mv docs/ARCHITECTURE.md docs/CRYPTO.md
|
||||
git mv FORMATS.md docs/FORMATS.md
|
||||
```
|
||||
|
||||
No content changes. Git tracks the renames so `git blame` / `git log --follow` survive.
|
||||
|
||||
### Commit 2 — Scope headers + "Next:" footers on the eight tour docs
|
||||
|
||||
Add the headers and footers verbatim as listed in the **Per-file scope definitions** section above. Also trim `README.md`'s current "Architecture" section to a one-paragraph pointer at `DESIGN.md` in the same commit.
|
||||
|
||||
### Commit 3 — Fix incoming links to old paths
|
||||
|
||||
Greppable list of paths to update:
|
||||
|
||||
| Old path | New path |
|
||||
|---|---|
|
||||
| `ARCHITECTURE.md` (top-level reference) | `DESIGN.md` |
|
||||
| `docs/ARCHITECTURE.md` | `docs/CRYPTO.md` |
|
||||
| `FORMATS.md` (top-level reference) | `docs/FORMATS.md` |
|
||||
|
||||
Known callsites to update:
|
||||
|
||||
- `CLAUDE.md` — the "Living docs — update discipline" table + the "Planning & design specs" core-references list.
|
||||
- `README.md` — the architecture tree on line ~160 shows `docs/architecture/` which doesn't exist; fix in this pass.
|
||||
- `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md` — cross-references to top-level ARCHITECTURE.md or FORMATS.md.
|
||||
- `docs/superpowers/specs/*.md` — some specs reference the old paths; update those whose specs are still load-bearing.
|
||||
|
||||
Verification command (run before this commit):
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.md' -E '(\bARCHITECTURE\.md\b|\bdocs/ARCHITECTURE\.md\b|\b/FORMATS\.md\b|^FORMATS\.md\b)' . \
|
||||
| grep -v docs/superpowers/test-runs/ \
|
||||
| grep -v docs/superpowers/audits/
|
||||
```
|
||||
|
||||
The grep should return zero matches after this commit (modulo intentional references in audit / test-run history, which we leave alone).
|
||||
|
||||
### Commit 4 — Update `CLAUDE.md`
|
||||
|
||||
Two changes to `CLAUDE.md`:
|
||||
|
||||
1. Update the "Living docs — update discipline" table with the new filenames (`DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`).
|
||||
2. Add three new rules (see **Maintenance discipline** below).
|
||||
3. Update the "Planning & design specs" core-references list to point at `docs/CRYPTO.md` / `docs/FORMATS.md` if it currently points elsewhere.
|
||||
|
||||
### Commit 5 — Verification
|
||||
|
||||
A read-through commit (no content changes; this is just where we confirm the work). The verification checklist in the **Verification** section below runs cleanly. If it doesn't, fix and amend the relevant commit.
|
||||
|
||||
## Maintenance discipline
|
||||
|
||||
Three rules added to `CLAUDE.md` to prevent the kind of drift the audit found:
|
||||
|
||||
1. **Scope-boundary check.** When editing a tour doc, verify the change fits the doc's scope header. If it doesn't, the change belongs in a different doc — move it instead of stretching the scope. (Concretely: a sentence about crypto added to `DESIGN.md` belongs in `docs/CRYPTO.md`; a wire-format table added to `docs/CRYPTO.md` belongs in `docs/FORMATS.md`.)
|
||||
|
||||
2. **Code-constant pinning.** When a tour doc cites a code constant (`VERSION_BYTE = 0x02`, `QUANT_STEP = 50.0`, `MIN_COPIES = 5`, `MANIFEST_SCHEMA_VERSION = 2`, etc.), the doc must cite the source file + line. When the underlying constant changes, grep for the citation pattern and update the docs together with the code change in the same commit. Most of the drift the audit found was code-constant drift; this rule attacks it at the source.
|
||||
|
||||
3. **New-doc rule.** When adding a tour doc, also update (a) `DESIGN.md`'s code-map, (b) the reading-order sequence (the "Next:" footer chain), and (c) the `CLAUDE.md` living-docs table. A new doc that doesn't appear in all three is not done.
|
||||
|
||||
## Verification
|
||||
|
||||
Run as part of Commit 5. All checks must pass.
|
||||
|
||||
1. **Scope-header presence.** Every tour doc (`README`, `DESIGN`, `docs/CRYPTO`, `docs/FORMATS`, `docs/SECURITY`, `crates/relicario-core/ARCHITECTURE`, `crates/relicario-cli/ARCHITECTURE`, `extension/ARCHITECTURE`) has its scope header at the top, matching the wording in this spec.
|
||||
|
||||
2. **"Next:" footer chain.** Each tour doc except `extension/ARCHITECTURE.md` ends with a `Next:` footer pointing at the next doc in the canonical order. `extension/ARCHITECTURE.md` ends with the "End of tour" pointer at STATUS/ROADMAP.
|
||||
|
||||
3. **No broken links.** Every link in every tour doc resolves to an existing file. Verified with a markdown link checker or by hand-grepping for `](./` / `](../` references and confirming the target exists.
|
||||
|
||||
4. **No old paths remain.** The grep in Commit 3 returns zero matches outside `docs/superpowers/test-runs/` and `docs/superpowers/audits/`.
|
||||
|
||||
5. **`CLAUDE.md` table is current.** The "Living docs — update discipline" table lists the new filenames; the three new discipline rules are present.
|
||||
|
||||
6. **Renames are git-tracked.** `git log --follow DESIGN.md` shows history continuous from the old `ARCHITECTURE.md`. Same for `docs/CRYPTO.md` and `docs/FORMATS.md`.
|
||||
|
||||
7. **README architecture section trimmed.** `README.md`'s mid-section "Architecture" is at most one paragraph and points at `DESIGN.md`.
|
||||
|
||||
## Out-of-scope safeties
|
||||
|
||||
Things this design intentionally does *not* address; flagging for honesty:
|
||||
|
||||
- **STATUS.md drift habit** (shipped work lingering as "in progress"): a behavioural issue, not structural. The audit caught it; the fix was manual. A future pass might add a release-checklist hook or a pre-tag CI gate.
|
||||
- **Per-crate `ARCHITECTURE.md` line-citation drift** (e.g., `main.rs:NNNN` references stale after handlers moved into `commands/`): partially addressed by rule 2 (code-constant pinning), but not fully. A future habit nudge — cite by function name, not by line number — is worth landing later but is cross-cutting and out of scope here.
|
||||
- **`docs/superpowers/specs/` and `plans/` accumulation**: intentional. Not touched.
|
||||
|
||||
## Footnote — alternative approaches considered
|
||||
|
||||
Three approaches were brainstormed before settling on this design (full details in the conversation that produced this spec, archived nowhere because that's how brainstorms work):
|
||||
|
||||
- **Approach B — README expands; supporting docs collapse.** Fold the top-level `ARCHITECTURE.md` and `docs/ARCHITECTURE.md` into one big doc (or into README). Rejected: the combined doc gets long, and "organic flow" suffers when one doc covers from quick-start to crypto pipeline to module boundaries. README starts to do too much.
|
||||
- **Approach C — Keep current files, add reading paths.** Add a top-level `READING-ORDER.md` and grow scope headers on each existing doc. Rejected: doesn't fix the three-files-named-`ARCHITECTURE.md` cognitive cost. The drift surface stays the same; we just navigate it better.
|
||||
- **Approach A — Tour-shaped + topic-named** *(chosen).* Filenames carry meaning, linear flow is unambiguous, drift surface shrinks by killing the 3× `ARCHITECTURE.md` overload.
|
||||
@@ -1,10 +1,6 @@
|
||||
# Architecture: relicario extension
|
||||
|
||||
> Strategic-depth doc for the `extension/` codebase. Pairs with `/CLAUDE.md`
|
||||
> at the repo root (project-level summary) and the typed-items design spec
|
||||
> under `docs/superpowers/specs/`. Things that are easy to recover from
|
||||
> reading code are deliberately omitted; things that are not — invariants,
|
||||
> multi-file control flow, design rationale — go here.
|
||||
> **Audience:** contributors editing the browser extension. This doc owns the bundle structure (popup, vault tab, background SW, content scripts), the SW ↔ popup message contract, the component / pane architecture, routing, and the build pipeline. **Does NOT own:** WASM crypto internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)), wire formats (see [../docs/FORMATS.md](../docs/FORMATS.md)), or threat model (see [../docs/SECURITY.md](../docs/SECURITY.md)).
|
||||
|
||||
## What this codebase is for
|
||||
|
||||
@@ -41,7 +37,7 @@ Firefox build (the vault tab is Chrome-only for the moment). Verify in
|
||||
| `service-worker` | `src/service-worker/index.ts` | extension SW / bg | yes — initialized lazily on first message |
|
||||
| `popup` | `src/popup/popup.ts` | popup.html | no — goes through SW |
|
||||
| `vault` | `src/vault/vault.ts` (Chrome only) | vault.html (tab) | no — goes through SW |
|
||||
| `setup` | `src/setup/setup.ts` | setup.html (tab) | yes — direct dynamic import (predates SW handle) |
|
||||
| `setup` | `src/setup/setup.ts` | setup.html (tab) | no — goes through SW (`create_vault`/`attach_vault`) |
|
||||
| `content` | `src/content/detector.ts` | host page (top frame only by router check) | no |
|
||||
|
||||
### What each bundle owns
|
||||
@@ -138,6 +134,10 @@ before any new render.
|
||||
`renderConcealedRow`, `renderSignatureBlock`, `renderSections*`)
|
||||
consumed by every type. Mounting is the caller's job; after mount,
|
||||
`wireFieldHandlers(scope)` binds the reveal/copy click handlers once.
|
||||
- `form-header.ts` — extracted `renderFormHeader({ title, subtitle, ...})`
|
||||
helper used by every type's `renderForm` (shared `.form-header` CSS,
|
||||
static "esc to cancel" subtitle in fullscreen mode). Takes an options
|
||||
object so callers don't need to remember positional argument order.
|
||||
- `generator-panel.ts` — inline password / passphrase generator. Mounts
|
||||
inside any host element; round-trips knob changes through the SW's
|
||||
`generate_password` / `generate_passphrase` (debounced 150ms). Has two
|
||||
@@ -150,36 +150,114 @@ before any new render.
|
||||
bytes via WASM (defense in depth — see
|
||||
`router/popup-only.ts:223-228`).
|
||||
- `settings.ts` — device-local UX settings (capture toggle, prompt
|
||||
style), trash/devices/sync-now buttons, blacklist editor.
|
||||
style), trash/devices/sync-now buttons, blacklist editor. Revamped in
|
||||
commit `299e7db` to split synced (vault) vs local (device) sections
|
||||
and surface the per-device session-timeout UI (radio + minutes input).
|
||||
- `settings-vault.ts` — vault-wide settings (retention, generator
|
||||
defaults, autofill origin acks). Reads/writes via the SW's
|
||||
`get_vault_settings` / `update_vault_settings`.
|
||||
- `trash.ts` — soft-delete listing with restore + purge buttons.
|
||||
- `devices.ts` — device list with revoke. Inline "register this device"
|
||||
flow lives here (banner shown when current device is not in the list);
|
||||
see commit `a7dbf35`.
|
||||
- `settings-security.ts` — security sub-pane of the vault-tab settings
|
||||
shell: three-state recovery QR display (hidden → revealed → printed)
|
||||
and an inline devices summary. Mounted from the settings left-nav.
|
||||
Restored from main in commit `8baef5b` after the Stream C real
|
||||
implementation landed.
|
||||
- `trash.ts` — soft-delete listing with per-item purge countdown
|
||||
(via `shared/relative-time.ts::daysUntilPurge`), glyph restore (`⤺`),
|
||||
and a bottom-right destructive "empty trash" button.
|
||||
- `devices.ts` — device list. Three-line rhythm per row: name + revoke
|
||||
glyph (`⊘` with inline two-step confirm — no browser modal), full
|
||||
SHA256 fingerprint (computed in-popup via `shared/ssh-fingerprint.ts`
|
||||
— no SW round-trip), `added X ago · by Y` meta. Inline "register this
|
||||
device" banner shown when current device is not in the list.
|
||||
- `field-history.ts` — audit-log of value changes on a single item;
|
||||
driven by the SW's `get_field_history` which calls into WASM
|
||||
`get_field_history(item_json)`.
|
||||
`get_field_history(item_json)`. Section header per field
|
||||
(`PASSWORD · N entries`); reveal/copy via explicit glyph buttons
|
||||
(decoupled from row click); revealed values colorized via
|
||||
`shared/password-coloring.ts`.
|
||||
- `item-history-index.ts` — top-level history pane: iterates the
|
||||
manifest and fans out one `get_field_history` per item, lists those
|
||||
with ≥1 entry sorted by recency. Click drills into `field-history.ts`
|
||||
for the per-item view. Reachable via `#history` (the sidebar slot)
|
||||
and from the URL.
|
||||
|
||||
### `src/vault/`
|
||||
|
||||
- `vault.ts` — fullscreen tab entry. Hash-based router (`#detail/<id>`,
|
||||
`#add/<type>`, `#trash`, `#devices`, `#settings`, `#settings-vault`,
|
||||
`#field-history`). Registers itself as the StateHost so all
|
||||
`popup/components/*` renderers run unchanged. Maintains its own
|
||||
`selectedItem` cache so hash navigation between already-loaded items
|
||||
doesn't refetch.
|
||||
- `vault.ts` (194 lines) — fullscreen tab entry, now a thin
|
||||
routing + state shell after the Phase 4 split. Registers itself as
|
||||
the StateHost so all `popup/components/*` renderers run unchanged,
|
||||
maintains its own `selectedItem` cache so hash navigation between
|
||||
already-loaded items doesn't refetch, and delegates DOM scaffolding,
|
||||
navigation, list/drawer/form rendering, and route dispatch to the
|
||||
sibling modules below. The hash-route set is
|
||||
`#detail/<id>`, `#add/<type>`, `#trash`, `#devices`, `#settings`,
|
||||
`#settings-vault`, `#history`, `#history/<id>`, `#backup`, `#import`.
|
||||
- `vault-context.ts` — the `VaultController` contract plus the shared
|
||||
types and pure helpers the split modules depend on. Added so the
|
||||
split is acyclic: the rendering modules import the controller
|
||||
interface from here rather than from `vault.ts`.
|
||||
- `vault-router.ts` — hash routing + pane dispatch + data loading,
|
||||
extracted to keep `vault.ts` ≤250 LOC. Owns `parseHash`; legacy
|
||||
`#field-history/<id>` URLs are normalized to `#history/<id>` here, but
|
||||
the internal view value stays `'field-history'` so the per-item pane
|
||||
renders unchanged.
|
||||
- `vault-shell.ts` — DOM scaffolding, color-scheme apply, and the
|
||||
`onMessage` wiring for the tab.
|
||||
- `vault-sidebar.ts` — sidebar categories nav, 80ms-debounced search
|
||||
(`SEARCH_DEBOUNCE_MS`), and the bottom-nav
|
||||
(`+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock`).
|
||||
Also owns the footer: 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 through
|
||||
`vault-status.ts`. 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).
|
||||
- `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.
|
||||
- `vault-list.ts` — the list pane and its row rendering.
|
||||
- `vault-drawer.ts` — drawer open/close/render plus
|
||||
`ensureDrawerClosedForRoute`, which closes the drawer on any
|
||||
non-list navigation.
|
||||
- `vault-form-wrapper.ts` — `renderFormWrapped` plus the sticky bar and
|
||||
header that wrap form panes.
|
||||
- `vault.html` / `vault.css` — sidebar + pane layout.
|
||||
|
||||
### `src/vault/components/`
|
||||
|
||||
Vault-tab-only panes (popup is too small for these workflows). Each
|
||||
exports `render…(app)` and a `teardown()`, same convention as
|
||||
`popup/components/*`.
|
||||
|
||||
- `backup-panel.ts` — `.relbak` export / restore UI. Routable as
|
||||
`#backup` (vault.ts case at :167). Drives the SW's backup handlers;
|
||||
the actual tar packing happens in `relicario-core` via WASM exports.
|
||||
- `import-panel.ts` — LastPass CSV importer surface. Routable as
|
||||
`#import` (vault.ts case at :168). Parses CSV client-side and pipes
|
||||
parsed rows through `add_item` SW messages.
|
||||
|
||||
### `src/setup/`
|
||||
|
||||
- `setup.ts` (1137 lines) — the wizard state machine. Six steps
|
||||
(0..5): mode picker (new vault / attach this device), host type
|
||||
(Gitea/GitHub), host config + connection test + repo probe, the
|
||||
forking step 3 (create-vault vs attach-this-device), device name,
|
||||
finish. Loads WASM directly. State-coupled `updateStrengthUi` stays
|
||||
here because it walks the live wizard state.
|
||||
- `setup.ts` (58 lines) — a thin UI-only shell after the Phase 3
|
||||
split: the render loop + progress track + boot + re-exports. No longer
|
||||
imports `relicario-wasm`; the wizard now drives vault creation/attach
|
||||
through the SW. Binds `clearWizardState` to
|
||||
`window.addEventListener('beforeunload', clearWizardState)`
|
||||
(`setup.ts:53`) and also calls it on `goto('mode')` (`setup.ts:44`).
|
||||
- `setup-steps.ts` (extracted in Phase 3) — the setup step registry +
|
||||
wizard state + `clearWizardState` + `finishSetup`. One-directional
|
||||
import (`setup.ts` → `setup-steps.ts`, no cycle). Crypto orchestration
|
||||
no longer lives in the wizard: the device step (where `deviceName`
|
||||
exists) fires `create_vault` and `attach_vault` SW messages instead of
|
||||
calling WASM directly. State-coupled `updateStrengthUi` stays here
|
||||
because it walks the live wizard state.
|
||||
- `setup-helpers.ts` (84 lines, extracted in commit `f79a67b`) — pure
|
||||
helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms
|
||||
debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the
|
||||
@@ -236,7 +314,23 @@ before any new render.
|
||||
`session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate,
|
||||
re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here
|
||||
with its own captured-tab verification (see Key flows). New in
|
||||
commit `a7dbf35`: `register_this_device`.
|
||||
commit `a7dbf35`: `register_this_device`. Phase 3 added
|
||||
`create_vault` and `attach_vault` (full SW-side vault
|
||||
creation/attach: embed/unlock, encrypt+push, `register_device` +
|
||||
`addDevice`, persist config+image, `session.setCurrent`; the failure
|
||||
path locks and frees the handle). The `lock` handler now also nulls
|
||||
`state.gitHost` (symmetric with session-expiry) so the status cache
|
||||
can't go stale across a lock→unlock. Phase 6 added `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`.
|
||||
- `router/content-callable.ts` — handler match arms for every
|
||||
`CONTENT_CALLABLE_TYPES` message. Origin always derived from
|
||||
`sender.tab.url`, never from message fields. `capture_save_login`
|
||||
@@ -250,7 +344,13 @@ before any new render.
|
||||
no www-stripping, no public-suffix), trash helpers
|
||||
(`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and
|
||||
attachment helpers (`addAttachmentToItem`, `removeAttachmentsFromItem`,
|
||||
with manifest summary sync).
|
||||
with manifest summary sync). Now also includes the
|
||||
`create_vault`/`attach_vault` orchestration handlers (Phase 3) and
|
||||
`handleGetVaultStatus(state)` (Phase 6) — synchronous, no network;
|
||||
returns the cached `{ ahead, behind, lastSyncAt, pendingItems }`. Its
|
||||
`Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks
|
||||
the `PopupState` import cycle and structurally forbids it from making
|
||||
a network call.
|
||||
- `session.ts` — single module-scope `SessionHandle | null`. α assumes
|
||||
one vault per install. Multi-vault would replace this with a `Map`
|
||||
keyed by vault id.
|
||||
@@ -264,6 +364,15 @@ before any new render.
|
||||
`putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory.
|
||||
`BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which
|
||||
attachment writes switch from the Contents API to the Git Data API.
|
||||
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: 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`.
|
||||
- `gitea.ts` / `github.ts` — the two GitHost implementations. Both use
|
||||
the host's Contents API for files under threshold, and Git Data API
|
||||
(blobs + tree + commit) for large attachment uploads. Auth differs
|
||||
@@ -285,7 +394,9 @@ before any new render.
|
||||
- `state.ts` — `StateHost` interface + module-scope singleton. Both
|
||||
`popup.ts` and `vault.ts` register themselves on boot. All
|
||||
`popup/components/*` import from here, never from popup.ts directly,
|
||||
so the same render code runs in both bundles.
|
||||
so the same render code runs in both bundles. Its `sendMessage`
|
||||
wrapper intercepts `vault_locked` responses (lifted out of `vault.ts`
|
||||
in Phase 4, so the intercept now applies uniformly to both bundles).
|
||||
- `types.ts` — TypeScript mirrors of the Rust core's serde shapes:
|
||||
`Item`, `ItemCore` (internally-tagged on `type`), `Field` and
|
||||
`FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`,
|
||||
@@ -829,3 +940,7 @@ still required before shipping.
|
||||
next to `relicario_wasm_bg.wasm`. The runtime calls
|
||||
`WebAssembly.instantiateStreaming(fetch(URL))` against a
|
||||
hardcoded path; we just hand it that path.
|
||||
|
||||
---
|
||||
|
||||
**End of tour.** For roadmap and in-flight work see [../STATUS.md](../STATUS.md) and [../ROADMAP.md](../ROADMAP.md).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Relicario",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Relicario",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "relicario-extension",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
|
||||
@@ -93,9 +93,21 @@ setupFillListener();
|
||||
scan();
|
||||
|
||||
// Watch for DOM changes (SPA navigation, dynamically loaded forms).
|
||||
const observer = new MutationObserver(() => {
|
||||
// Plan C Phase 5: SPA churn fires the MutationObserver many times per
|
||||
// second. Trailing-edge debounce coalesces bursts so we run the full
|
||||
// scan() at most once per quiet 200ms window.
|
||||
const SCAN_DEBOUNCE_MS = 200;
|
||||
let scanTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function scheduleScan(): void {
|
||||
if (scanTimer !== undefined) clearTimeout(scanTimer);
|
||||
scanTimer = setTimeout(() => {
|
||||
scanTimer = undefined;
|
||||
scan();
|
||||
});
|
||||
}, SCAN_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(scheduleScan);
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
|
||||
@@ -33,11 +33,16 @@ describe('devices view', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// The component fires list_devices + list_revoked in parallel via Promise.all,
|
||||
// so every render needs both mocked. Helper makes the per-test setup readable.
|
||||
function mockListPair(devices: unknown[], revoked: unknown[] = []): void {
|
||||
(sendMessage as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, data: { devices } })
|
||||
.mockResolvedValueOnce({ ok: true, data: { revoked } });
|
||||
}
|
||||
|
||||
it('renders empty state when no devices', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: { devices: [] },
|
||||
});
|
||||
mockListPair([]);
|
||||
|
||||
await renderDevices(app);
|
||||
|
||||
@@ -45,15 +50,10 @@ describe('devices view', () => {
|
||||
});
|
||||
|
||||
it('renders devices with "you" indicator on current device', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
devices: [
|
||||
mockListPair([
|
||||
{ name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 },
|
||||
{ name: 'CLI', public_key: 'def', added_at: 500 },
|
||||
],
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
await renderDevices(app);
|
||||
|
||||
@@ -68,23 +68,15 @@ describe('devices view', () => {
|
||||
|
||||
it('shows unregistered banner when current device not in list', async () => {
|
||||
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
devices: [{ name: 'CLI', public_key: 'abc', added_at: 1000 }],
|
||||
},
|
||||
});
|
||||
mockListPair([{ name: 'CLI', public_key: 'abc', added_at: 1000 }]);
|
||||
|
||||
await renderDevices(app);
|
||||
|
||||
expect(app.innerHTML).toContain('This device is not registered');
|
||||
expect(app.innerHTML).toContain("This device isn't registered");
|
||||
});
|
||||
|
||||
it('back button navigates to list', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: { devices: [] },
|
||||
});
|
||||
mockListPair([]);
|
||||
|
||||
await renderDevices(app);
|
||||
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||||
@@ -94,10 +86,7 @@ describe('devices view', () => {
|
||||
|
||||
it('clicking register button reveals an inline name input', async () => {
|
||||
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] },
|
||||
});
|
||||
mockListPair([{ name: 'CLI', public_key: 'k', added_at: 1 }]);
|
||||
|
||||
await renderDevices(app);
|
||||
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
|
||||
@@ -106,15 +95,43 @@ describe('devices view', () => {
|
||||
expect(app.querySelector<HTMLButtonElement>('#register-confirm-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('confirming register sends register_this_device with the entered name', async () => {
|
||||
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||
// Initial list_devices.
|
||||
// Plan C Phase 5 — defensive Promise.allSettled:
|
||||
// a rejected secondary feed (list_revoked) should not kill the whole render.
|
||||
it('renders devices when revoked list fails (load-error slot shown)', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
|
||||
.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await renderDevices(app);
|
||||
|
||||
// Primary list still rendered.
|
||||
expect(app.innerHTML).toContain('CLI');
|
||||
// Inline fallback slot present.
|
||||
expect(app.innerHTML).toContain("Couldn't load revoked devices");
|
||||
});
|
||||
|
||||
it('renders devices when revoked list returns {ok:false}', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
|
||||
.mockResolvedValueOnce({ ok: false, error: 'list_revoked_failed' });
|
||||
|
||||
await renderDevices(app);
|
||||
|
||||
expect(app.innerHTML).toContain('CLI');
|
||||
expect(app.innerHTML).toContain("Couldn't load revoked devices");
|
||||
});
|
||||
|
||||
it('confirming register sends register_this_device with the entered name', async () => {
|
||||
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||
// Initial render: list_devices + list_revoked.
|
||||
mockListPair([{ name: 'CLI', public_key: 'k', added_at: 1 }]);
|
||||
// register_this_device.
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
// Re-render's list_devices.
|
||||
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }, { name: 'Test Browser', public_key: 'q', added_at: 2 }] } });
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
// Re-render: list_devices + list_revoked.
|
||||
mockListPair([
|
||||
{ name: 'CLI', public_key: 'k', added_at: 1 },
|
||||
{ name: 'Test Browser', public_key: 'q', added_at: 2 },
|
||||
]);
|
||||
// Re-render also re-reads device_name from storage.
|
||||
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Test Browser' });
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('field-history view', () => {
|
||||
expect(app.innerHTML).toContain('No history available');
|
||||
});
|
||||
|
||||
it('renders history entries masked by default', async () => {
|
||||
it('renders history entries masked by default with section-header and glyph buttons', async () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
@@ -53,9 +53,17 @@ describe('field-history view', () => {
|
||||
|
||||
await renderFieldHistory(app);
|
||||
|
||||
// Masked by default
|
||||
expect(app.innerHTML).toContain('••••••••••••');
|
||||
expect(app.innerHTML).not.toContain('secret123');
|
||||
expect(app.innerHTML).toContain('current');
|
||||
// Section-header per field with uppercase name + entry count
|
||||
expect(app.innerHTML).toContain('section-header');
|
||||
expect(app.innerHTML).toContain('PASSWORD · 2 entries');
|
||||
// Current entry annotation
|
||||
expect(app.innerHTML).toContain('current · ');
|
||||
// Explicit glyph buttons (reveal + copy) on each entry
|
||||
expect(app.querySelectorAll('[data-entry-reveal]').length).toBe(2);
|
||||
expect(app.querySelectorAll('[data-entry-copy]').length).toBe(2);
|
||||
});
|
||||
|
||||
it('back button navigates to detail', async () => {
|
||||
|
||||
@@ -40,19 +40,30 @@ describe('settings-vault', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true });
|
||||
// Default: get_session_config returns inactivity/15, everything else ok
|
||||
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
|
||||
if (msg.type === 'get_session_config') {
|
||||
return { ok: true, data: { config: { mode: 'inactivity', minutes: 15 } } };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with seeded vault-settings values', () => {
|
||||
it('renders with seeded vault-settings values', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderVaultSettings(app);
|
||||
expect(app.textContent).toContain('vault settings');
|
||||
// Initial synchronous render paints the vault settings section headers
|
||||
expect(app.querySelector('.section-header')?.textContent).toContain('VAULT SETTINGS');
|
||||
expect(app.textContent).toContain('github.com');
|
||||
expect(app.textContent).toContain('example.com');
|
||||
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
|
||||
expect(trashSel.value).toBe('days:30');
|
||||
const histSel = document.getElementById('history-retention') as HTMLSelectElement;
|
||||
expect(histSel.value).toBe('forever');
|
||||
// After get_session_config resolves, SESSION row appears
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(app.textContent).toContain('SESSION');
|
||||
expect(app.textContent).toContain('after inactivity');
|
||||
});
|
||||
|
||||
it('renders origin acks sorted by recency (descending)', () => {
|
||||
@@ -70,7 +81,7 @@ describe('settings-vault', () => {
|
||||
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
|
||||
trashSel.value = 'forever';
|
||||
trashSel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
expect(saveBtn.disabled).toBe(false);
|
||||
expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('revoke button removes origin from pending and enables save', () => {
|
||||
@@ -95,4 +106,28 @@ describe('settings-vault', () => {
|
||||
expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com');
|
||||
expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com');
|
||||
});
|
||||
|
||||
it('section headers render in correct order: VAULT SETTINGS, THIS DEVICE, ACTIONS', () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderVaultSettings(app);
|
||||
const headers = Array.from(document.querySelectorAll('.section-header')).map((e) => e.textContent?.trim());
|
||||
expect(headers[0]).toContain('VAULT SETTINGS');
|
||||
expect(headers[1]).toContain('THIS DEVICE');
|
||||
expect(headers[2]).toContain('ACTIONS');
|
||||
});
|
||||
|
||||
it('subtitle shows "no changes" initially', () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderVaultSettings(app);
|
||||
expect(app.querySelector('.settings-header__sub')?.textContent).toBe('no changes');
|
||||
});
|
||||
|
||||
it('subtitle shows "unsaved · esc to cancel" after making a change', () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderVaultSettings(app);
|
||||
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
|
||||
trashSel.value = 'forever';
|
||||
trashSel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
expect(document.querySelector('.settings-header__sub')?.textContent).toContain('unsaved');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,18 +24,35 @@ function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||
},
|
||||
local: {
|
||||
get: vi.fn(() => Promise.resolve({})),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
},
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
function settingsResponses() {
|
||||
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
||||
// After the Stream B left-nav restructure (bd6a301) and the management-surfaces
|
||||
// revamp, renderSettings makes these calls in this order:
|
||||
// 1. is_unlocked (gates vault-only sections)
|
||||
// 2. get_settings + get_blacklist (parallel) (Autofill is the default section)
|
||||
function mockDefaultLanding(opts: { unlocked?: boolean } = {}) {
|
||||
const unlocked = opts.unlocked ?? false;
|
||||
(sendMessage as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, data: { unlocked } })
|
||||
.mockResolvedValueOnce({ ok: true, data: { settings: { captureEnabled: false, captureStyle: 'bar' } } })
|
||||
.mockResolvedValueOnce({ ok: true, data: { blacklist: [] } });
|
||||
}
|
||||
|
||||
async function navigateToDisplay(app: HTMLElement): Promise<void> {
|
||||
const btn = app.querySelector<HTMLButtonElement>('[data-section="display"]')!;
|
||||
btn.click();
|
||||
// Allow renderDisplaySection's async loadColorScheme + DOM writes to settle.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
describe('settings view', () => {
|
||||
let app: HTMLElement;
|
||||
|
||||
@@ -45,42 +62,45 @@ describe('settings view', () => {
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
it('renders a Sync now button', async () => {
|
||||
it('renders the left-nav with the seven sections', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
|
||||
expect(app.querySelector('#sync-now-btn')).not.toBeNull();
|
||||
const sections = ['autofill', 'display', 'security', 'generator', 'retention', 'backup', 'import'];
|
||||
for (const s of sections) {
|
||||
expect(app.querySelector(`[data-section="${s}"]`)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
||||
it('lands on the Autofill section by default and renders the capture toggle', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' });
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: 'get_settings' });
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: 'get_blacklist' });
|
||||
expect(app.querySelector<HTMLInputElement>('#capture-enabled')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('toggling capture-enabled sends an update_settings message', async () => {
|
||||
mockChromeStorage();
|
||||
mockDefaultLanding();
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await renderSettings(app);
|
||||
app.querySelector<HTMLButtonElement>('#sync-now-btn')!.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const cb = app.querySelector<HTMLInputElement>('#capture-enabled')!;
|
||||
cb.checked = true;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ type: 'sync' });
|
||||
const status = app.querySelector('#sync-status')!;
|
||||
expect(status.textContent).toMatch(/synced/i);
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'update_settings',
|
||||
settings: { captureEnabled: true },
|
||||
});
|
||||
|
||||
it('shows the error when sync fails', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
||||
|
||||
await renderSettings(app);
|
||||
app.querySelector<HTMLButtonElement>('#sync-now-btn')!.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const status = app.querySelector('#sync-status')!;
|
||||
expect(status.textContent).toMatch(/remote_unreachable/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,12 +115,13 @@ describe('settings Display section', () => {
|
||||
|
||||
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
await navigateToDisplay(app);
|
||||
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#digit-color');
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color');
|
||||
expect(digitInput).not.toBeNull();
|
||||
expect(symbolInput).not.toBeNull();
|
||||
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||
@@ -111,32 +132,35 @@ describe('settings Display section', () => {
|
||||
mockChromeStorage({
|
||||
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||
});
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
await navigateToDisplay(app);
|
||||
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#digit-color');
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color');
|
||||
expect(digitInput!.value).toBe('#112233');
|
||||
expect(symbolInput!.value).toBe('#aabbcc');
|
||||
});
|
||||
|
||||
it('renders a color-preview-swatch element', async () => {
|
||||
it('renders a color-preview swatch element', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
await navigateToDisplay(app);
|
||||
|
||||
expect(app.querySelector('#display-swatch')).not.toBeNull();
|
||||
expect(app.querySelector('#color-preview')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('changing digit color calls saveColorScheme with updated scheme', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
await navigateToDisplay(app);
|
||||
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#digit-color')!;
|
||||
digitInput.value = '#ff0000';
|
||||
digitInput.dispatchEvent(new Event('change'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -151,11 +175,12 @@ describe('settings Display section', () => {
|
||||
|
||||
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
|
||||
mockChromeStorage();
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
await navigateToDisplay(app);
|
||||
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color')!;
|
||||
symbolInput.value = '#00ff00';
|
||||
symbolInput.dispatchEvent(new Event('change'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -172,19 +197,21 @@ describe('settings Display section', () => {
|
||||
mockChromeStorage({
|
||||
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||
});
|
||||
settingsResponses();
|
||||
mockDefaultLanding();
|
||||
|
||||
await renderSettings(app);
|
||||
await navigateToDisplay(app);
|
||||
|
||||
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
|
||||
const resetBtn = app.querySelector<HTMLButtonElement>('#reset-colors')!;
|
||||
resetBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
|
||||
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
|
||||
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||
const digitInput = app.querySelector<HTMLInputElement>('#digit-color')!;
|
||||
const symbolInput = app.querySelector<HTMLInputElement>('#symbol-color')!;
|
||||
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,8 @@ describe('trash view', () => {
|
||||
await renderTrash(app);
|
||||
|
||||
expect(app.innerHTML).toContain('Test Login');
|
||||
expect(app.innerHTML).toContain('restore');
|
||||
expect(app.querySelector('[data-restore]')).not.toBeNull();
|
||||
expect(app.innerHTML).toContain('purges in');
|
||||
expect(app.querySelector('#empty-trash-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { Device } from '../../shared/types';
|
||||
import { relativeTime } from '../../shared/relative-time';
|
||||
import { sshFingerprint } from '../../shared/ssh-fingerprint';
|
||||
import { GLYPH_REVOKE } from '../../shared/glyphs';
|
||||
|
||||
interface RevokedEntry {
|
||||
name: string;
|
||||
@@ -10,16 +13,6 @@ interface RevokedEntry {
|
||||
revoked_by: string;
|
||||
}
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - unixSec;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||||
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||
}
|
||||
|
||||
function detectDefaultDeviceName(): string {
|
||||
const ua = navigator.userAgent ?? '';
|
||||
const platform = (navigator.platform ?? '').toLowerCase();
|
||||
@@ -38,60 +31,101 @@ export function teardown(): void {
|
||||
// No cleanup needed
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-C P2: defensive per-slot rendering. The active list is the primary
|
||||
* feed — if it fails entirely, we still surface an error page. The
|
||||
* revoked list is secondary — its failure renders an inline "couldn't
|
||||
* load" slot but doesn't kill the page.
|
||||
*/
|
||||
function revokedLoadErrorHtml(): string {
|
||||
return `
|
||||
<details class="revoked-section">
|
||||
<summary class="muted">▸ revoked devices</summary>
|
||||
<div class="revoked-section__body">
|
||||
<p class="muted">Couldn't load revoked devices.</p>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
// Get current device name from local storage
|
||||
const stored = await chrome.storage.local.get(['device_name']);
|
||||
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
||||
|
||||
// Fetch active device list and revoked list in parallel
|
||||
const [devicesResp, revokedResp] = await Promise.all([
|
||||
// Fetch active device list and revoked list in parallel. allSettled so a
|
||||
// rejected secondary feed doesn't kill the whole render.
|
||||
const [devicesSettled, revokedSettled] = await Promise.allSettled([
|
||||
sendMessage({ type: 'list_devices' }),
|
||||
sendMessage({ type: 'list_revoked' }),
|
||||
]);
|
||||
|
||||
if (!devicesResp.ok) {
|
||||
if (devicesSettled.status === 'rejected' || !devicesSettled.value.ok) {
|
||||
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
||||
const revokedDevices: RevokedEntry[] = revokedResp.ok
|
||||
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
|
||||
// devicesSettled.value.ok is true here (guarded above), so .data is present.
|
||||
const devicesData = (devicesSettled.value as { ok: true; data: unknown }).data;
|
||||
const devices = (devicesData as { devices: Device[] }).devices;
|
||||
const revokedOk = revokedSettled.status === 'fulfilled' && revokedSettled.value.ok;
|
||||
const revokedDevices: RevokedEntry[] = revokedOk
|
||||
? ((revokedSettled.value as { ok: true; data: unknown }).data as { revoked: RevokedEntry[] }).revoked
|
||||
: [];
|
||||
|
||||
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
||||
|
||||
// Precompute fingerprints for all active devices. allSettled so one bad
|
||||
// public key doesn't kill the whole list — fall back to '(unknown)'.
|
||||
const fingerprints = new Map<string, string>();
|
||||
const fpResults = await Promise.allSettled(
|
||||
devices.map((d) => sshFingerprint(d.public_key).then((fp) => [d.name, fp] as const)),
|
||||
);
|
||||
for (let i = 0; i < devices.length; i += 1) {
|
||||
const r = fpResults[i];
|
||||
if (r.status === 'fulfilled' && r.value[1]) {
|
||||
fingerprints.set(r.value[0], r.value[1]);
|
||||
} else {
|
||||
fingerprints.set(devices[i].name, '(unknown)');
|
||||
}
|
||||
}
|
||||
|
||||
const activeDevicesHtml = devices.length === 0
|
||||
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||||
: devices.map((d) => {
|
||||
const isCurrentDevice = d.name === currentDeviceName;
|
||||
const fp = fingerprints.get(d.name) ?? '(unknown)';
|
||||
const addedBy = d.added_by && d.added_by !== 'unknown' ? ` · by ${escapeHtml(d.added_by)}` : '';
|
||||
return `
|
||||
<div class="device-row">
|
||||
<div class="device-row__info">
|
||||
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
|
||||
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
|
||||
<div class="device-row" data-device="${escapeHtml(d.name)}">
|
||||
<div class="device-row__head">
|
||||
<span class="device-row__name">${escapeHtml(d.name)}</span>
|
||||
${isCurrentDevice
|
||||
? '<span class="device-row__you">← you</span>'
|
||||
: `<button class="glyph-btn" data-danger data-revoke="${escapeHtml(d.name)}" title="revoke" aria-label="revoke ${escapeHtml(d.name)}">${GLYPH_REVOKE}</button>`}
|
||||
</div>
|
||||
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
|
||||
<div class="fingerprint">${escapeHtml(fp)}</div>
|
||||
<div class="device-row__meta">added ${escapeHtml(relativeTime(d.added_at))}${addedBy}</div>
|
||||
<div class="device-row__confirm" data-confirm-for="${escapeHtml(d.name)}" hidden></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
|
||||
<details class="revoked-section" style="margin-top:16px;">
|
||||
<summary class="muted" style="cursor:pointer;font-size:0.85em;">
|
||||
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
|
||||
</summary>
|
||||
<div style="margin-top:8px;">
|
||||
const revokedSectionHtml = !revokedOk
|
||||
? revokedLoadErrorHtml()
|
||||
: revokedDevices.length === 0 ? '' : `
|
||||
<details class="revoked-section">
|
||||
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
|
||||
<div class="revoked-section__body">
|
||||
${revokedDevices.map((r) => `
|
||||
<div class="device-row device-row--revoked">
|
||||
<div class="device-row__info">
|
||||
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;">
|
||||
<div class="device-row__head">
|
||||
<span class="device-row__name" style="text-decoration:line-through;opacity:0.6;">
|
||||
${escapeHtml(r.name)}
|
||||
</span>
|
||||
<span class="device-row__meta">
|
||||
revoked ${relativeTime(r.revoked_at)}
|
||||
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="device-row__meta">
|
||||
revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
@@ -107,11 +141,14 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
</div>
|
||||
${!isRegistered ? `
|
||||
<div class="device-banner">
|
||||
<span>⚠ This device is not registered</span>
|
||||
<div class="device-banner__title">This device isn't registered.</div>
|
||||
<p class="device-banner__body muted">Registering generates an ed25519 keypair and adds the public key to <code>.relicario/devices.json</code> on the remote.</p>
|
||||
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
||||
</div>
|
||||
` : ''}
|
||||
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
|
||||
${activeDevicesHtml}
|
||||
${!revokedOk ? `<div class="section-header">REVOKED · ?</div>` : (revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : '')}
|
||||
${revokedSectionHtml}
|
||||
</div>
|
||||
`;
|
||||
@@ -160,20 +197,43 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.addEventListener('click', () => {
|
||||
const name = btn.dataset.revoke;
|
||||
if (!name) return;
|
||||
if (!confirm(`Revoke ${name}? This device will no longer be authorized.`)) return;
|
||||
|
||||
const panel = document.querySelector<HTMLElement>(`[data-confirm-for="${CSS.escape(name)}"]`);
|
||||
if (!panel) return;
|
||||
panel.hidden = false;
|
||||
panel.innerHTML = `
|
||||
<p class="device-row__confirm-text">
|
||||
Revoke this device? It won't be able to sign commits or push changes after revocation.
|
||||
</p>
|
||||
<div class="device-row__confirm-actions">
|
||||
<button class="btn" data-revoke-cancel="${escapeHtml(name)}">cancel</button>
|
||||
<button class="btn btn-danger" data-revoke-confirm="${escapeHtml(name)}">revoke</button>
|
||||
</div>
|
||||
`;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
|
||||
panel.querySelector('[data-revoke-cancel]')?.addEventListener('click', () => {
|
||||
panel.hidden = true;
|
||||
panel.innerHTML = '';
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
panel.querySelector('[data-revoke-confirm]')?.addEventListener('click', async () => {
|
||||
const confirmBtn = panel.querySelector<HTMLButtonElement>('[data-revoke-confirm]')!;
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = '...';
|
||||
const result = await sendMessage({ type: 'revoke_device', name });
|
||||
if (result.ok) {
|
||||
await sendMessage({ type: 'sync' });
|
||||
renderDevices(app);
|
||||
} else {
|
||||
setState({ error: result.error });
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'revoke';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,18 +3,8 @@
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import { colorizePassword } from '../../shared/password-coloring';
|
||||
import type { FieldHistoryView } from '../../shared/types';
|
||||
import { GLYPH_COPY } from '../../shared/glyphs';
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - unixSec;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
|
||||
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||
}
|
||||
import { relativeTime } from '../../shared/relative-time';
|
||||
import { GLYPH_COPY, GLYPH_REVEAL, GLYPH_HIDE } from '../../shared/glyphs';
|
||||
|
||||
const revealedSet = new Set<string>();
|
||||
|
||||
@@ -68,27 +58,28 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||||
const isRevealed = revealedSet.has(entryKey);
|
||||
const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••';
|
||||
valueStore.set(entryKey, value);
|
||||
const revealGlyph = isRevealed ? GLYPH_HIDE : GLYPH_REVEAL;
|
||||
|
||||
return `
|
||||
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
|
||||
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
||||
<div class="history-entry__meta">
|
||||
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
||||
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
||||
<div class="history-entry__meta muted">
|
||||
${isCurrent ? '<span class="history-entry__current">current · </span>' : ''}
|
||||
${isCurrent ? 'set' : 'changed'} ${escapeHtml(relativeTime(timestamp))}
|
||||
</div>
|
||||
<div class="history-entry__actions">
|
||||
<button class="glyph-btn" data-entry-reveal="${escapeHtml(entryKey)}" title="${isRevealed ? 'hide' : 'reveal'}" aria-label="${isRevealed ? 'hide' : 'reveal'}">${revealGlyph}</button>
|
||||
<button class="glyph-btn" data-entry-copy="${escapeHtml(entryKey)}" title="copy" aria-label="copy">${GLYPH_COPY}</button>
|
||||
</div>
|
||||
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let content = '';
|
||||
for (const field of history) {
|
||||
if (history.length > 1) {
|
||||
content += `<div class="history-field-label">${escapeHtml(field.field_name)}</div>`;
|
||||
}
|
||||
// Current value first
|
||||
const entryCount = field.entries.length + 1; // +1 for current
|
||||
content += `<div class="section-header">${escapeHtml(field.field_name.toUpperCase())} · ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}</div>`;
|
||||
content += renderEntry(field.field_id, field.current_value, item.modified, true);
|
||||
// Historical values
|
||||
for (const entry of field.entries) {
|
||||
content += renderEntry(field.field_id, entry.value, entry.changed_at, false);
|
||||
}
|
||||
@@ -118,17 +109,14 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||||
// Wire handlers
|
||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||
|
||||
// Toggle reveal on click
|
||||
app.querySelectorAll<HTMLElement>('.history-entry').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).classList.contains('history-entry__copy')) return;
|
||||
const key = el.dataset.entry;
|
||||
// Reveal toggle via explicit glyph button (decoupled from row click)
|
||||
app.querySelectorAll<HTMLButtonElement>('[data-entry-reveal]').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const key = btn.dataset.entryReveal;
|
||||
if (!key) return;
|
||||
if (revealedSet.has(key)) {
|
||||
revealedSet.delete(key);
|
||||
} else {
|
||||
revealedSet.add(key);
|
||||
}
|
||||
if (revealedSet.has(key)) revealedSet.delete(key);
|
||||
else revealedSet.add(key);
|
||||
renderFieldHistory(app);
|
||||
});
|
||||
});
|
||||
|
||||
130
extension/src/popup/components/item-history-index.ts
Normal file
130
extension/src/popup/components/item-history-index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/// History index — lists items that have any field history, sorted by most-recent
|
||||
/// change. Clicking a row drills into the per-item view (field-history.ts).
|
||||
///
|
||||
/// Implementation: iterate manifest, fetch each item via get_field_history, check
|
||||
/// for ≥1 non-empty history-tracked field, emit an entry per qualifying item.
|
||||
|
||||
import { getState, sendMessage, navigate, setState, escapeHtml } from '../../shared/state';
|
||||
import type { Item, ItemId, ManifestEntry } from '../../shared/types';
|
||||
import { relativeTime } from '../../shared/relative-time';
|
||||
import {
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
|
||||
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
|
||||
} from '../../shared/glyphs';
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
login: GLYPH_TYPE_LOGIN,
|
||||
secure_note: GLYPH_TYPE_SECURE_NOTE,
|
||||
identity: GLYPH_TYPE_IDENTITY,
|
||||
card: GLYPH_TYPE_CARD,
|
||||
key: GLYPH_TYPE_KEY,
|
||||
document: GLYPH_TYPE_DOCUMENT,
|
||||
totp: GLYPH_TYPE_TOTP,
|
||||
};
|
||||
|
||||
interface HistoryIndexEntry {
|
||||
id: ItemId;
|
||||
type: string;
|
||||
title: string;
|
||||
changeCount: number;
|
||||
lastChangedAt: number;
|
||||
}
|
||||
|
||||
export function teardown(): void {
|
||||
// No persistent state.
|
||||
}
|
||||
|
||||
export async function renderItemHistoryIndex(app: HTMLElement): Promise<void> {
|
||||
const state = getState();
|
||||
const manifest: Array<[ItemId, ManifestEntry]> = state.entries ?? [];
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="history-header">
|
||||
<button class="btn" id="back-btn">← back</button>
|
||||
<h3 style="margin:0;">history</h3>
|
||||
</div>
|
||||
<p class="muted" style="margin:8px 0;">Scanning items…</p>
|
||||
</div>
|
||||
`;
|
||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
|
||||
const entries: HistoryIndexEntry[] = [];
|
||||
await Promise.all(manifest.map(async ([id, manifestEntry]) => {
|
||||
if (manifestEntry.trashed_at !== undefined && manifestEntry.trashed_at !== null) return;
|
||||
const resp = await sendMessage({ type: 'get_field_history', id });
|
||||
if (!resp.ok) return;
|
||||
const history = (resp.data as { history: Array<{ entries: Array<{ changed_at: number }> }> }).history;
|
||||
let totalCount = 0;
|
||||
let mostRecent = 0;
|
||||
for (const field of history) {
|
||||
totalCount += field.entries.length;
|
||||
for (const e of field.entries) {
|
||||
if (e.changed_at > mostRecent) mostRecent = e.changed_at;
|
||||
}
|
||||
}
|
||||
if (totalCount > 0) {
|
||||
entries.push({
|
||||
id,
|
||||
type: manifestEntry.type,
|
||||
title: manifestEntry.title,
|
||||
changeCount: totalCount,
|
||||
lastChangedAt: mostRecent,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
entries.sort((a, b) => b.lastChangedAt - a.lastChangedAt);
|
||||
|
||||
if (entries.length === 0) {
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="history-header">
|
||||
<button class="btn" id="back-btn">← back</button>
|
||||
<h3 style="margin:0;">history</h3>
|
||||
</div>
|
||||
<p class="muted" style="text-align:center;margin-top:32px;">
|
||||
No field history yet.<br>
|
||||
Edits to passwords, TOTP secrets, and concealed fields will appear here.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
return;
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="history-header">
|
||||
<button class="btn" id="back-btn">← back</button>
|
||||
<h3 style="margin:0;">history</h3>
|
||||
</div>
|
||||
<p class="muted" style="margin:8px 0;">${entries.length} item${entries.length === 1 ? '' : 's'} have field history</p>
|
||||
<div class="section-header"> </div>
|
||||
${entries.map((e) => `
|
||||
<div class="history-index-row" data-id="${escapeHtml(e.id)}">
|
||||
<span class="history-index-row__icon">${TYPE_ICONS[e.type] ?? '◻'}</span>
|
||||
<div class="history-index-row__info">
|
||||
<span class="history-index-row__title">${escapeHtml(e.title)}</span>
|
||||
<span class="history-index-row__meta muted">${e.changeCount} change${e.changeCount === 1 ? '' : 's'} · last ${escapeHtml(relativeTime(e.lastChangedAt))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
app.querySelectorAll<HTMLElement>('.history-index-row').forEach((row) => {
|
||||
row.addEventListener('click', async () => {
|
||||
const id = row.dataset.id as ItemId;
|
||||
const itemResp = await sendMessage({ type: 'get_item', id });
|
||||
if (!itemResp.ok) {
|
||||
setState({ error: 'Failed to load item' });
|
||||
return;
|
||||
}
|
||||
const item = (itemResp.data as { item: Item }).item;
|
||||
setState({ selectedId: id, selectedItem: item, historyItemId: id });
|
||||
navigate('field-history');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,19 +6,22 @@ import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } f
|
||||
import type {
|
||||
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
|
||||
} from '../../shared/types';
|
||||
import type { SessionTimeoutConfig } from '../../shared/messages';
|
||||
import { relativeTime } from '../../shared/relative-time';
|
||||
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||
import { teardownSettingsCommon } from './settings';
|
||||
import { GLYPH_NEXT } from '../../shared/glyphs';
|
||||
|
||||
let pendingSettings: VaultSettings | null = null;
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let pendingSession: SessionTimeoutConfig | null = null;
|
||||
let baseSession: SessionTimeoutConfig | null = null;
|
||||
|
||||
export function teardown(): void {
|
||||
closeGeneratorPanel();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
|
||||
pendingSettings = null;
|
||||
pendingSession = null;
|
||||
baseSession = null;
|
||||
}
|
||||
|
||||
// --- Retention helpers ---
|
||||
@@ -65,17 +68,6 @@ function generatorSummary(req: GeneratorRequest): string {
|
||||
return `BIP39, ${req.word_count} words, "${req.separator}" separator, ${req.capitalization}`;
|
||||
}
|
||||
|
||||
// --- Time formatting ---
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - unixSec;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
export function renderVaultSettings(app: HTMLElement): void {
|
||||
@@ -87,20 +79,42 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
}
|
||||
pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings;
|
||||
|
||||
sendMessage({ type: 'get_session_config' }).then((resp) => {
|
||||
// Guard against clobbering the user's in-flight edits if they tap a radio
|
||||
// before the SW responds — tiny window but real.
|
||||
if (resp.ok && !pendingSession) {
|
||||
baseSession = (resp.data as { config: SessionTimeoutConfig }).config;
|
||||
pendingSession = JSON.parse(JSON.stringify(baseSession)) as SessionTimeoutConfig;
|
||||
rerender();
|
||||
}
|
||||
});
|
||||
|
||||
function rerender(): void {
|
||||
if (!pendingSettings) return;
|
||||
const acksEntries = Object.entries(pendingSettings.autofill_origin_acks)
|
||||
.sort(([, a], [, b]) => b - a);
|
||||
|
||||
const dirty: boolean = JSON.stringify(pendingSettings) !== JSON.stringify(base)
|
||||
|| !!(baseSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession));
|
||||
const subtitle = dirty ? 'unsaved · esc to cancel' : 'no changes';
|
||||
|
||||
const sessionMode = pendingSession?.mode ?? 'inactivity';
|
||||
const sessionMinutes = pendingSession && pendingSession.mode === 'inactivity'
|
||||
? pendingSession.minutes : 15;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="settings-header">
|
||||
<button class="btn" id="back-btn">← back</button>
|
||||
<h3 style="margin:0;">vault settings</h3>
|
||||
<h3 style="margin:0;">settings</h3>
|
||||
<span class="muted settings-header__sub">${escapeHtml(subtitle)}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-header">VAULT SETTINGS · synced</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">retention</div>
|
||||
<div class="settings-section__title">RETENTION</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">trash</span>
|
||||
<select id="trash-retention">
|
||||
@@ -114,7 +128,7 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">field history</span>
|
||||
<span class="settings-row__label">history</span>
|
||||
<select id="history-retention">
|
||||
<option value="forever">Forever</option>
|
||||
<option value="last_n:3">Last 3</option>
|
||||
@@ -128,28 +142,16 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">generator</div>
|
||||
<div class="settings-section__title">GENERATOR</div>
|
||||
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
|
||||
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨</button>
|
||||
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨ configure</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">autofill origins</div>
|
||||
${acksEntries.length === 0
|
||||
? `<p class="muted">No origins acknowledged yet.</p>`
|
||||
: acksEntries.map(([host, ts]) => `
|
||||
<div class="ack-row">
|
||||
<span class="ack-row__host">${escapeHtml(host)}</span>
|
||||
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
|
||||
<button class="ack-row__revoke" data-revoke="${escapeHtml(host)}">revoke</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">attachments</div>
|
||||
<div class="settings-section__title">ATTACHMENTS</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">max file size</span>
|
||||
<span class="settings-row__label">max size</span>
|
||||
<select id="attachment-cap">
|
||||
<option value="5242880">5 MB</option>
|
||||
<option value="10485760">10 MB</option>
|
||||
@@ -160,43 +162,61 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">backup & restore</div>
|
||||
<div class="settings-section__title">AUTOFILL ORIGINS</div>
|
||||
${acksEntries.length === 0
|
||||
? `<p class="muted">No origins acknowledged yet.</p>`
|
||||
: acksEntries.map(([host, ts]) => `
|
||||
<div class="ack-row">
|
||||
<span class="ack-row__host">${escapeHtml(host)}</span>
|
||||
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
|
||||
<button class="glyph-btn" data-danger title="revoke" data-revoke="${escapeHtml(host)}">⊘</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="section-header">THIS DEVICE · local</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">SESSION</div>
|
||||
<div class="settings-row">
|
||||
<button class="btn" id="open-backup">Backup & restore ${GLYPH_NEXT}</button>
|
||||
<label><input type="radio" name="session-mode" value="every_time" ${sessionMode === 'every_time' ? 'checked' : ''}> lock every time</label>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label><input type="radio" name="session-mode" value="inactivity" ${sessionMode === 'inactivity' ? 'checked' : ''}> after inactivity</label>
|
||||
<select id="session-minutes" ${sessionMode !== 'inactivity' ? 'disabled' : ''}>
|
||||
<option value="5">5 min</option>
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header">ACTIONS</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">import</div>
|
||||
<div class="settings-row">
|
||||
<button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button>
|
||||
<button class="btn" id="open-backup">Backup & restore ${GLYPH_NEXT}</button>
|
||||
<button class="btn" id="open-import">Import from… ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button class="btn" id="discard-btn">discard</button>
|
||||
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
||||
<button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set current select values
|
||||
(document.getElementById('trash-retention') as HTMLSelectElement).value =
|
||||
trashRetentionToValue(pendingSettings.trash_retention);
|
||||
(document.getElementById('history-retention') as HTMLSelectElement).value =
|
||||
historyRetentionToValue(pendingSettings.field_history_retention);
|
||||
const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760;
|
||||
(document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue);
|
||||
(document.getElementById('session-minutes') as HTMLSelectElement).value = String(sessionMinutes);
|
||||
|
||||
wireHandlers();
|
||||
updateSaveEnabled();
|
||||
}
|
||||
|
||||
function updateSaveEnabled(): void {
|
||||
const saveBtn = document.getElementById('save-btn') as HTMLButtonElement | null;
|
||||
if (!saveBtn || !pendingSettings || !base) return;
|
||||
const changed = JSON.stringify(pendingSettings) !== JSON.stringify(base);
|
||||
saveBtn.disabled = !changed;
|
||||
}
|
||||
|
||||
function wireHandlers(): void {
|
||||
@@ -208,13 +228,13 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
||||
if (!pendingSettings) return;
|
||||
pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
|
||||
updateSaveEnabled();
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('history-retention')?.addEventListener('change', (e) => {
|
||||
if (!pendingSettings) return;
|
||||
pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
|
||||
updateSaveEnabled();
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('attachment-cap')?.addEventListener('change', (e) => {
|
||||
@@ -224,7 +244,7 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
...pendingSettings.attachment_caps,
|
||||
per_attachment_max_bytes: bytes,
|
||||
};
|
||||
updateSaveEnabled();
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
||||
@@ -255,8 +275,18 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
if (!pendingSettings) return;
|
||||
const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings });
|
||||
if (resp.ok) {
|
||||
// Refresh cached state and navigate back.
|
||||
if (!resp.ok) {
|
||||
setState({ error: resp.error });
|
||||
return;
|
||||
}
|
||||
if (pendingSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession)) {
|
||||
const sessResp = await sendMessage({ type: 'update_session_config', config: pendingSession });
|
||||
if (!sessResp.ok) {
|
||||
setState({ error: sessResp.error });
|
||||
return;
|
||||
}
|
||||
baseSession = JSON.parse(JSON.stringify(pendingSession)) as SessionTimeoutConfig;
|
||||
}
|
||||
const refreshed = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (refreshed.ok && refreshed.data) {
|
||||
const vs = (refreshed.data as { settings: VaultSettings }).settings;
|
||||
@@ -265,8 +295,26 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
navigate('list');
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>('input[name="session-mode"]').forEach((el) => {
|
||||
el.addEventListener('change', () => {
|
||||
const mode = (document.querySelector<HTMLInputElement>('input[name="session-mode"]:checked')?.value ?? 'inactivity') as 'every_time' | 'inactivity';
|
||||
if (mode === 'every_time') {
|
||||
pendingSession = { mode: 'every_time' };
|
||||
} else {
|
||||
setState({ error: resp.error });
|
||||
const mins = Number((document.getElementById('session-minutes') as HTMLSelectElement).value);
|
||||
pendingSession = { mode: 'inactivity', minutes: mins };
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('session-minutes')?.addEventListener('change', (e) => {
|
||||
const mins = Number((e.target as HTMLSelectElement).value);
|
||||
if (pendingSession?.mode === 'inactivity') {
|
||||
pendingSession = { mode: 'inactivity', minutes: mins };
|
||||
rerender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,13 +53,29 @@ export async function renderSettings(container: HTMLElement): Promise<void> {
|
||||
await renderSection(activeSection);
|
||||
}
|
||||
|
||||
export function teardownSettings(): void {
|
||||
/**
|
||||
* Common cleanup invoked by both the device-settings teardown
|
||||
* (settings.ts) and the vault-settings teardown (settings-vault.ts).
|
||||
* Centralized to avoid the "regression class with known prior leaks"
|
||||
* DEV-C P2 flagged.
|
||||
*
|
||||
* Closes the generator popover and detaches the supplied keydown
|
||||
* handler from the document if present. Returns the new handler value
|
||||
* (always null), so the caller can do `handler = teardownSettingsCommon(handler)`.
|
||||
*/
|
||||
export function teardownSettingsCommon(
|
||||
keyHandler: ((e: KeyboardEvent) => void) | null,
|
||||
): null {
|
||||
closeGeneratorPanel();
|
||||
teardownSecuritySection();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
if (keyHandler) {
|
||||
document.removeEventListener('keydown', keyHandler);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function teardownSettings(): void {
|
||||
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
|
||||
teardownSecuritySection();
|
||||
pendingVaultSettings = null;
|
||||
sessionHandle = null;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
|
||||
import { relativeTime, daysUntilPurge } from '../../shared/relative-time';
|
||||
import {
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
|
||||
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
|
||||
GLYPH_RESTORE,
|
||||
} from '../../shared/glyphs';
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
@@ -17,21 +19,6 @@ const TYPE_ICONS: Record<string, string> = {
|
||||
totp: GLYPH_TYPE_TOTP,
|
||||
};
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - unixSec;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function daysUntilPurge(trashedAt: number, retention: VaultSettings['trash_retention']): number | null {
|
||||
if (retention.kind === 'forever') return null;
|
||||
const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400);
|
||||
return Math.max(0, retention.value - trashedDaysAgo);
|
||||
}
|
||||
|
||||
export function teardown(): void {
|
||||
// No cleanup needed
|
||||
}
|
||||
@@ -39,7 +26,6 @@ export function teardown(): void {
|
||||
export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
const state = getState();
|
||||
|
||||
// Fetch trashed items
|
||||
const resp = await sendMessage({ type: 'list_trashed' });
|
||||
if (!resp.ok) {
|
||||
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
|
||||
@@ -49,7 +35,6 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items;
|
||||
const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 };
|
||||
|
||||
// Calculate days until oldest auto-purges
|
||||
let oldestPurgeDays: number | null = null;
|
||||
if (items.length > 0 && retention.kind === 'days') {
|
||||
const oldest = items[items.length - 1][1];
|
||||
@@ -59,8 +44,8 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
const headerInfo = items.length === 0
|
||||
? ''
|
||||
: oldestPurgeDays !== null
|
||||
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest auto-purges in ${oldestPurgeDays}d`
|
||||
: `${items.length} item${items.length === 1 ? '' : 's'}`;
|
||||
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest purges in ${oldestPurgeDays} days`
|
||||
: `${items.length} item${items.length === 1 ? '' : 's'} · retained forever`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
@@ -71,25 +56,30 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
|
||||
${items.length === 0
|
||||
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
||||
: items.map(([id, entry]) => `
|
||||
: `<div class="section-header"> </div>
|
||||
${items.map(([id, entry]) => {
|
||||
const purgeIn = daysUntilPurge(entry.trashed_at ?? 0, retention);
|
||||
const purgeStr = purgeIn === null ? 'retained forever' : `purges in ${purgeIn} days`;
|
||||
return `
|
||||
<div class="trash-row" data-id="${escapeHtml(id)}">
|
||||
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
|
||||
<div class="trash-row__info">
|
||||
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
||||
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
|
||||
<span class="trash-row__meta muted">trashed ${escapeHtml(relativeTime(entry.trashed_at ?? 0))} · ${escapeHtml(purgeStr)}</span>
|
||||
</div>
|
||||
<button class="trash-row__restore" data-restore="${escapeHtml(id)}">restore</button>
|
||||
<button class="glyph-btn" data-restore="${escapeHtml(id)}" title="restore" aria-label="restore ${escapeHtml(entry.title)}">${GLYPH_RESTORE}</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}).join('')}`
|
||||
}
|
||||
${items.length > 0 ? `
|
||||
<div style="margin-top:16px;text-align:center;">
|
||||
<button class="btn danger" id="empty-trash-btn">empty trash</button>
|
||||
<div class="trash-footer">
|
||||
<button class="btn btn-danger" id="empty-trash-btn">empty trash</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire handlers
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((btn) => {
|
||||
@@ -104,14 +94,14 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
renderTrash(app);
|
||||
} else {
|
||||
setState({ error: result.error });
|
||||
btn.disabled = false;
|
||||
btn.textContent = '⤺';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('empty-trash-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) return;
|
||||
const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'deleting...';
|
||||
@@ -121,6 +111,8 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
renderTrash(app);
|
||||
} else {
|
||||
setState({ error: result.error });
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'empty trash';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
|
||||
|
||||
// --- State ---
|
||||
|
||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
|
||||
|
||||
export interface PopupState {
|
||||
view: View;
|
||||
entries: Array<[ItemId, ManifestEntry]>;
|
||||
selectedId: ItemId | null;
|
||||
selectedItem: Item | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
// Captured tab snapshot taken at popup-open. Used by fill_credentials
|
||||
// to guard against TOCTOU navigation — the SW re-checks this URL's
|
||||
// hostname against the tab's live URL before forwarding fill_credentials
|
||||
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
newType: import('../shared/types').ItemType | null;
|
||||
vaultSettings: import('../shared/types').VaultSettings | null;
|
||||
generatorDefaults: import('../shared/types').GeneratorRequest | null;
|
||||
historyItemId: import('../shared/types').ItemId | null;
|
||||
}
|
||||
import type { View, PopupState } from '../shared/popup-state';
|
||||
|
||||
let currentState: PopupState = {
|
||||
view: 'locked',
|
||||
|
||||
@@ -631,16 +631,6 @@ textarea {
|
||||
.sig-block--red { border-left-color: #ab2b20; }
|
||||
|
||||
/* --- custom-section rendering (β₂ slice 1) --- */
|
||||
.section-header {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #21262d;
|
||||
color: #8b949e;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.section-separator {
|
||||
margin: 10px 0 4px;
|
||||
border: 0;
|
||||
@@ -1120,54 +1110,18 @@ textarea {
|
||||
.trash-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background: #161b22;
|
||||
margin-bottom: 6px;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.trash-row__icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trash-row__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trash-row__title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #c9d1d9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.trash-row__meta {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.trash-row__restore {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trash-row__restore:hover {
|
||||
background: #2ea043;
|
||||
}
|
||||
|
||||
.trash-row__restore:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
.trash-row__icon { font-size: 14px; }
|
||||
.trash-row__info { flex: 1; display: flex; flex-direction: column; }
|
||||
.trash-row__title { color: var(--text); }
|
||||
.trash-row__meta { font-size: 11px; color: var(--text-muted); }
|
||||
.trash-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* --- Devices view --- */
|
||||
@@ -1179,69 +1133,47 @@ textarea {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-banner {
|
||||
.device-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.device-row__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #3d1f00;
|
||||
border: 1px solid #9e6a03;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #f0c674;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #161b22;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.device-row__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-row__name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.device-row__name { color: var(--text); }
|
||||
.device-row__you {
|
||||
font-size: 11px;
|
||||
color: #58a6ff;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.device-row__meta {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.device-row__confirm {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.device-row__confirm-text { margin: 0 0 8px 0; color: var(--text); }
|
||||
.device-row__confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
|
||||
.device-row__revoke {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: #da3633;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-row__revoke:hover {
|
||||
background: #f85149;
|
||||
}
|
||||
|
||||
.device-row__revoke:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
.device-banner {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-pane);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.device-banner__title { margin-bottom: 4px; }
|
||||
.device-banner__body { font-size: 12px; margin: 0 0 10px 0; }
|
||||
|
||||
/* --- Field history view --- */
|
||||
|
||||
@@ -1259,66 +1191,28 @@ textarea {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.history-field-label {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
|
||||
.history-entry {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #161b22;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.history-entry:hover {
|
||||
background: #1c2128;
|
||||
}
|
||||
|
||||
.history-entry__value {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
font-family: ui-monospace, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.history-entry__value.masked {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.history-entry__value.revealed {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.history-entry__value.masked { letter-spacing: 1px; }
|
||||
.history-entry__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
grid-column: 1 / 2;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.history-entry__current {
|
||||
color: #58a6ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-entry__copy {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.history-entry__copy:hover {
|
||||
opacity: 0.8;
|
||||
.history-entry__actions {
|
||||
grid-row: 1 / 3;
|
||||
grid-column: 2 / 3;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* --- Type selection --- */
|
||||
@@ -1700,6 +1594,40 @@ textarea {
|
||||
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
|
||||
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||
|
||||
/* --- Shared utility classes for management surfaces (settings/devices/trash/history) --- */
|
||||
|
||||
.section-header {
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 4px;
|
||||
margin: 16px 0 10px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.section-header:first-child { margin-top: 0; }
|
||||
|
||||
.glyph-btn[data-danger]:hover { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
.kv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.kv-row > .k { color: var(--text-muted); }
|
||||
.kv-row > .v { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.fingerprint {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
word-break: break-all; /* wraps to two lines in popup (~360px) */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* === Settings layout === */
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
@@ -1788,3 +1716,32 @@ textarea {
|
||||
|
||||
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
|
||||
.setting-card__actions { display: flex; gap: 8px; }
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.settings-header__sub {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.history-index-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-index-row:hover { background: var(--bg-input); }
|
||||
.history-index-row__icon { font-size: 14px; }
|
||||
.history-index-row__info { flex: 1; display: flex; flex-direction: column; }
|
||||
.history-index-row__title { color: var(--text); }
|
||||
.history-index-row__meta { font-size: 11px; }
|
||||
|
||||
@@ -3,10 +3,19 @@ import { readDevices, addDevice, revokeDevice } from '../devices';
|
||||
import type { GitHost } from '../git-host';
|
||||
|
||||
function makeGitHost(devicesJson = '{"devices":[]}'): GitHost {
|
||||
let stored = devicesJson;
|
||||
// Per-path storage — revokeDevice writes devices.json AND revoked.json,
|
||||
// so a single slot would corrupt the second read.
|
||||
const files = new Map<string, string>();
|
||||
files.set('.relicario/devices.json', devicesJson);
|
||||
return {
|
||||
readFile: vi.fn().mockImplementation(async () => new TextEncoder().encode(stored)),
|
||||
writeFile: vi.fn().mockImplementation(async (_p, bytes) => { stored = new TextDecoder().decode(bytes); }),
|
||||
readFile: vi.fn().mockImplementation(async (path: string) => {
|
||||
const content = files.get(path);
|
||||
if (content === undefined) throw new Error(`404: ${path}`);
|
||||
return new TextEncoder().encode(content);
|
||||
}),
|
||||
writeFile: vi.fn().mockImplementation(async (path: string, bytes: Uint8Array) => {
|
||||
files.set(path, new TextDecoder().decode(bytes));
|
||||
}),
|
||||
deleteFile: vi.fn(),
|
||||
listDir: vi.fn(),
|
||||
putBlob: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as timer from '../session-timer';
|
||||
import { READ_ONLY_CONTENT_CALLABLE } from '../session-timer';
|
||||
|
||||
describe('session-timer', () => {
|
||||
beforeEach(() => {
|
||||
@@ -97,3 +98,29 @@ describe('session-timer', () => {
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('READ_ONLY_CONTENT_CALLABLE — inversion exclusion set', () => {
|
||||
// The SW handler invokes resetTimer() on every message whose type is NOT
|
||||
// in this set. These cases encode the documented inversion contract from
|
||||
// Plan C Phase 5: popup-only messages reset, content-callable writes
|
||||
// reset, only passive content reads (currently just get_autofill_candidates)
|
||||
// do NOT reset.
|
||||
|
||||
it('popup-only message would reset the timer (not in exclusion set)', () => {
|
||||
// e.g. list_items — popup interaction is unambiguously active use
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('list_items')).toBe(false);
|
||||
});
|
||||
|
||||
it('content-callable get_autofill_candidates does NOT reset (in exclusion set)', () => {
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('get_autofill_candidates')).toBe(true);
|
||||
});
|
||||
|
||||
it('content-callable capture_save_login DOES reset (write op = active use)', () => {
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('capture_save_login')).toBe(false);
|
||||
});
|
||||
|
||||
it('content-callable check_credential DOES reset', () => {
|
||||
// Asking "is this credential already saved" is user-initiated.
|
||||
expect(READ_ONLY_CONTENT_CALLABLE.has('check_credential')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
56
extension/src/service-worker/__tests__/storage.test.ts
Normal file
56
extension/src/service-worker/__tests__/storage.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist }
|
||||
from '../storage';
|
||||
|
||||
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||
const store: Record<string, unknown> = { ...initial };
|
||||
(global as { chrome: unknown }).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((keys: string | string[]) => {
|
||||
const arr = Array.isArray(keys) ? keys : [keys];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of arr) if (k in store) out[k] = store[k];
|
||||
return Promise.resolve(out);
|
||||
}),
|
||||
set: vi.fn((kv: Record<string, unknown>) => {
|
||||
Object.assign(store, kv);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('service-worker/storage', () => {
|
||||
beforeEach(() => { mockChromeStorage(); });
|
||||
|
||||
it('loadDeviceSettings returns default when storage is empty', async () => {
|
||||
const s = await loadDeviceSettings();
|
||||
expect(s.captureEnabled).toBe(false);
|
||||
expect(s.captureStyle).toBe('bar');
|
||||
});
|
||||
|
||||
it('loadDeviceSettings returns stored value', async () => {
|
||||
mockChromeStorage({ relicarioSettings: { captureEnabled: true, captureStyle: 'toast' } });
|
||||
const s = await loadDeviceSettings();
|
||||
expect(s.captureEnabled).toBe(true);
|
||||
expect(s.captureStyle).toBe('toast');
|
||||
});
|
||||
|
||||
it('saveDeviceSettings persists', async () => {
|
||||
const store = mockChromeStorage();
|
||||
await saveDeviceSettings({ captureEnabled: true, captureStyle: 'bar' });
|
||||
expect(store.relicarioSettings).toEqual({ captureEnabled: true, captureStyle: 'bar' });
|
||||
});
|
||||
|
||||
it('loadBlacklist returns empty array by default', async () => {
|
||||
expect(await loadBlacklist()).toEqual([]);
|
||||
});
|
||||
|
||||
it('saveBlacklist / loadBlacklist round-trips', async () => {
|
||||
await saveBlacklist(['example.com', 'evil.test']);
|
||||
expect(await loadBlacklist()).toEqual(['example.com', 'evil.test']);
|
||||
});
|
||||
});
|
||||
53
extension/src/service-worker/__tests__/vault-status.test.ts
Normal file
53
extension/src/service-worker/__tests__/vault-status.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { handleGetVaultStatus } from '../vault';
|
||||
import type { Manifest, ManifestEntry } from '../../shared/types';
|
||||
|
||||
// The handler only reads gitHost's three cache fields, so the test feeds a
|
||||
// minimal object — the handler's Pick-typed param makes full GitHost mocking
|
||||
// unnecessary.
|
||||
const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) =>
|
||||
({ lastSyncAt, ahead, behind });
|
||||
|
||||
function manifestWith(activeCount: number, trashedCount = 0): Manifest {
|
||||
const items: Record<string, ManifestEntry> = {};
|
||||
for (let i = 0; i < activeCount; i++) {
|
||||
items[`a${i}`] = { trashed_at: undefined } as ManifestEntry;
|
||||
}
|
||||
for (let i = 0; i < trashedCount; i++) {
|
||||
items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry;
|
||||
}
|
||||
return { items } as Manifest;
|
||||
}
|
||||
|
||||
describe('handleGetVaultStatus', () => {
|
||||
it('returns zeros when never synced and no manifest', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: null });
|
||||
expect(resp).toEqual({
|
||||
ok: true,
|
||||
data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reflects cached sync state + active (non-trashed) item count', () => {
|
||||
const resp = handleGetVaultStatus({
|
||||
gitHost: cache(1234567890, 3, 1),
|
||||
manifest: manifestWith(5, 2),
|
||||
});
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.data).toEqual({
|
||||
ahead: 3, behind: 1, lastSyncAt: 1234567890, pendingItems: 5,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns vault_locked error when gitHost is null', () => {
|
||||
expect(handleGetVaultStatus({ gitHost: null, manifest: null }))
|
||||
.toEqual({ ok: false, error: 'vault_locked' });
|
||||
});
|
||||
|
||||
it('is synchronous — no network round-trip', () => {
|
||||
const resp = handleGetVaultStatus({ gitHost: cache(0), manifest: null });
|
||||
expect(resp).not.toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
304
extension/src/service-worker/__tests__/vault.test.ts
Normal file
304
extension/src/service-worker/__tests__/vault.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vault from '../vault';
|
||||
import * as session from '../session';
|
||||
import type { PopupState } from '../router/popup-only';
|
||||
import type { GitHost } from '../git-host';
|
||||
import * as gitHostMod from '../git-host';
|
||||
|
||||
// --- Mock git-host module ---
|
||||
// createGitHost is called internally by handleCreateVault / handleAttachVault;
|
||||
// we need to intercept it and return a fake GitHost. uint8ArrayToBase64 must
|
||||
// still work — vault.ts calls it for the imageBase64 storage value.
|
||||
|
||||
// Shared factory used both inside vi.mock and in beforeEach re-wire.
|
||||
function makeHostMock(): GitHost & { _calls: Record<string, unknown[][]> } {
|
||||
const calls: Record<string, unknown[][]> = {
|
||||
writeFileCreateOnly: [],
|
||||
writeFile: [],
|
||||
readFile: [],
|
||||
};
|
||||
return {
|
||||
_calls: calls,
|
||||
readFile: vi.fn().mockImplementation(async (path: string) => {
|
||||
// Serve the vault-meta files needed by fetchVaultMeta + attach flow.
|
||||
if (path === '.relicario/salt') return new Uint8Array(32);
|
||||
if (path === '.relicario/params.json') {
|
||||
return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}');
|
||||
}
|
||||
if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]);
|
||||
// .relicario/devices.json throws so readDevices falls back to [].
|
||||
throw new Error(`404: ${path}`);
|
||||
}),
|
||||
writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => {
|
||||
calls.writeFile.push(args);
|
||||
}),
|
||||
writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => {
|
||||
calls.writeFileCreateOnly.push(args);
|
||||
}),
|
||||
deleteFile: vi.fn(),
|
||||
listDir: vi.fn().mockResolvedValue([]),
|
||||
lastCommit: vi.fn().mockResolvedValue(null),
|
||||
putBlob: vi.fn(),
|
||||
getBlob: vi.fn(),
|
||||
deleteBlob: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../git-host', async () => {
|
||||
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
|
||||
|
||||
// Expose a handle so tests can grab the last-created fake host.
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = null;
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createGitHost: vi.fn().mockImplementation(() => {
|
||||
const h = makeHostMock();
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||
return h;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Chrome storage mock ---
|
||||
|
||||
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||
const store: Record<string, unknown> = { ...initial };
|
||||
(global as { chrome: unknown }).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((keys: string | string[]) => {
|
||||
const arr = Array.isArray(keys) ? keys : [keys];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of arr) if (k in store) out[k] = store[k];
|
||||
return Promise.resolve(out);
|
||||
}),
|
||||
set: vi.fn((kv: Record<string, unknown>) => {
|
||||
Object.assign(store, kv);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return store;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeFakeHandle() {
|
||||
return { free: vi.fn() };
|
||||
}
|
||||
|
||||
function makeWasm(overrides: Record<string, unknown> = {}) {
|
||||
const fakeHandle = makeFakeHandle();
|
||||
return {
|
||||
_handle: fakeHandle,
|
||||
embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])),
|
||||
unlock: vi.fn(() => fakeHandle),
|
||||
manifest_encrypt: vi.fn(() => new Uint8Array([9])),
|
||||
manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })),
|
||||
default_vault_settings_json: vi.fn(() => '{}'),
|
||||
settings_encrypt: vi.fn(() => new Uint8Array([8])),
|
||||
register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })),
|
||||
lock: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(wasm: ReturnType<typeof makeWasm>): PopupState {
|
||||
return {
|
||||
manifest: null,
|
||||
gitHost: null,
|
||||
wasm,
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_MSG = {
|
||||
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
|
||||
passphrase: 'pw',
|
||||
carrierImageBytes: new Uint8Array([0, 0, 0]).buffer,
|
||||
deviceName: 'Dev',
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('handleCreateVault', () => {
|
||||
let setCurrent: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChromeStorage();
|
||||
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Test 1 (happy path): returns ok:true with expected data and correct side effects', async () => {
|
||||
const wasm = makeWasm();
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleCreateVault(BASE_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) throw new Error('expected ok:true');
|
||||
|
||||
// Response shape
|
||||
expect(resp.data.referenceImageBytes).toBeInstanceOf(Uint8Array);
|
||||
expect(resp.data.deviceName).toBe('Dev');
|
||||
expect(resp.data.recoveryQrAvailable).toBe(true);
|
||||
|
||||
// Fake GitHost captures the four writeFileCreateOnly calls
|
||||
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
|
||||
expect(fakeHost).not.toBeNull();
|
||||
const wfco = fakeHost!.writeFileCreateOnly as ReturnType<typeof vi.fn>;
|
||||
const paths = wfco.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(paths).toContain('.relicario/salt');
|
||||
expect(paths).toContain('.relicario/params.json');
|
||||
expect(paths).toContain('manifest.enc');
|
||||
expect(paths).toContain('settings.enc');
|
||||
|
||||
// register_device called with the device name
|
||||
expect(wasm.register_device).toHaveBeenCalledWith('Dev');
|
||||
|
||||
// chrome.storage.local.set called with vaultConfig + imageBase64 + device_name
|
||||
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
|
||||
.chrome.storage.local.set.mock.calls;
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [kv] of chromeSets) Object.assign(merged, kv);
|
||||
expect(merged).toHaveProperty('vaultConfig');
|
||||
expect(merged).toHaveProperty('imageBase64');
|
||||
expect(merged).toHaveProperty('device_name', 'Dev');
|
||||
|
||||
// session.setCurrent was called (ownership transferred — handle NOT freed)
|
||||
expect(setCurrent).toHaveBeenCalled();
|
||||
expect(wasm._handle.free).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 2 (failure path — early throw): ok:false, no writeFileCreateOnly calls', async () => {
|
||||
const wasm = makeWasm({
|
||||
embed_image_secret: vi.fn(() => { throw new Error('embed failed'); }),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleCreateVault(BASE_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (resp.ok) throw new Error('expected ok:false');
|
||||
expect(resp.error).toBeTruthy();
|
||||
expect(resp.error.length).toBeGreaterThan(0);
|
||||
|
||||
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
|
||||
// No GitHost was created at all (failed before createGitHost call), OR
|
||||
// if somehow created, no writeFileCreateOnly calls happened.
|
||||
if (fakeHost) {
|
||||
expect((fakeHost.writeFileCreateOnly as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 3 (handle cleanup on mid-flight failure): lock + free called, ok:false', async () => {
|
||||
const wasm = makeWasm({
|
||||
manifest_encrypt: vi.fn(() => { throw new Error('encrypt failed'); }),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleCreateVault(BASE_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
|
||||
// unlock succeeded (handle was acquired), manifest_encrypt failed after that.
|
||||
// Finally block must: lock(handle) then handle.free().
|
||||
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
|
||||
expect(wasm._handle.free).toHaveBeenCalled();
|
||||
|
||||
// Ownership was NOT transferred — setCurrent must NOT have been called.
|
||||
expect(setCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- attach_vault ---
|
||||
|
||||
const ATTACH_MSG = {
|
||||
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
|
||||
passphrase: 'pw',
|
||||
referenceImageBytes: new Uint8Array([1, 2, 3]).buffer,
|
||||
deviceName: 'Dev2',
|
||||
};
|
||||
|
||||
describe('handleAttachVault', () => {
|
||||
let setCurrent: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChromeStorage();
|
||||
// Re-wire createGitHost: vi.restoreAllMocks() in the create-vault afterEach
|
||||
// strips the mockImplementation from the vi.fn(), leaving it returning undefined.
|
||||
// We re-establish it here so each attach test starts with a fresh fake host.
|
||||
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => {
|
||||
const h = makeHostMock();
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||
return h;
|
||||
});
|
||||
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Test 1 (happy path): returns ok:true, state populated, handle ownership transferred', async () => {
|
||||
const wasm = makeWasm();
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) throw new Error('expected ok:true');
|
||||
expect(resp.data.deviceName).toBe('Dev2');
|
||||
|
||||
// WASM calls in order: unlock → manifest_decrypt (verification) → register_device
|
||||
expect(wasm.unlock).toHaveBeenCalled();
|
||||
expect(wasm.manifest_decrypt).toHaveBeenCalled();
|
||||
expect(wasm.register_device).toHaveBeenCalledWith('Dev2');
|
||||
|
||||
// chrome.storage.local.set received vaultConfig + imageBase64 + device_name
|
||||
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
|
||||
.chrome.storage.local.set.mock.calls;
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [kv] of chromeSets) Object.assign(merged, kv);
|
||||
expect(merged).toHaveProperty('vaultConfig');
|
||||
expect(merged).toHaveProperty('imageBase64');
|
||||
expect(merged).toHaveProperty('device_name', 'Dev2');
|
||||
|
||||
// session.setCurrent called — ownership transferred; handle NOT freed
|
||||
expect(setCurrent).toHaveBeenCalled();
|
||||
expect(wasm._handle.free).not.toHaveBeenCalled();
|
||||
|
||||
// State wired up
|
||||
expect(state.manifest).not.toBeNull();
|
||||
expect(state.gitHost).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Test 2 (wrong credentials — manifest_decrypt throws): ok:false, handle locked+freed, no side effects', async () => {
|
||||
const wasm = makeWasm({
|
||||
manifest_decrypt: vi.fn(() => { throw new Error('AEAD verification failed'); }),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (resp.ok) throw new Error('expected ok:false');
|
||||
expect(resp.error).toBeTruthy();
|
||||
expect(resp.error.length).toBeGreaterThan(0);
|
||||
|
||||
// register_device must NOT be called (we failed before it)
|
||||
expect(wasm.register_device).not.toHaveBeenCalled();
|
||||
|
||||
// Finally block must lock then free the handle we own
|
||||
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
|
||||
expect(wasm._handle.free).toHaveBeenCalled();
|
||||
|
||||
// Session must NOT have been updated
|
||||
expect(setCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,15 @@ export interface GitHost {
|
||||
/// Delete a blob from the repo. Currently identical to deleteFile;
|
||||
/// kept distinct for symmetry with putBlob.
|
||||
deleteBlob(path: string, message: string): Promise<void>;
|
||||
|
||||
/// Cached sync metadata, populated by the `sync` handler — get_vault_status
|
||||
/// reads these without any network call. lastSyncAt is unix SECONDS (or null
|
||||
/// until the first sync). ahead/behind exist for parity with `relicario
|
||||
/// status`; the extension writes straight to the host (no local commit
|
||||
/// graph), so in practice they stay 0.
|
||||
lastSyncAt: number | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
/// Pre-base64 byte size at which putBlob switches from Contents API to
|
||||
|
||||
@@ -20,6 +20,9 @@ export class GiteaHost implements GitHost {
|
||||
private keysUrl: string;
|
||||
private branch: string = 'main';
|
||||
private headers: Record<string, string>;
|
||||
lastSyncAt: number | null = null;
|
||||
ahead = 0;
|
||||
behind = 0;
|
||||
|
||||
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
||||
// Remove trailing slash from hostUrl
|
||||
|
||||
@@ -17,6 +17,9 @@ export class GitHubHost implements GitHost {
|
||||
private commitsUrl: string;
|
||||
private branch: string = 'main';
|
||||
private headers: Record<string, string>;
|
||||
lastSyncAt: number | null = null;
|
||||
ahead = 0;
|
||||
behind = 0;
|
||||
|
||||
constructor(repoPath: string, apiToken: string) {
|
||||
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
/// forwards every message into router/index.route().
|
||||
|
||||
import type { Request, Response, SessionTimeoutConfig } from '../shared/messages';
|
||||
import { CONTENT_CALLABLE_TYPES } from '../shared/messages';
|
||||
import type { RouterState } from './router/index';
|
||||
import { route } from './router/index';
|
||||
import * as vault from './vault';
|
||||
import { clearCurrent } from './session';
|
||||
import * as sessionTimer from './session-timer';
|
||||
import { READ_ONLY_CONTENT_CALLABLE } from './session-timer';
|
||||
|
||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||
@@ -53,6 +53,9 @@ sessionTimer.onExpired(() => {
|
||||
console.log('[relicario sw] session expired — locking vault');
|
||||
clearCurrent();
|
||||
state.manifest = null;
|
||||
// Plan C Phase 5: don't leak the cached git-host client across a lock.
|
||||
// The initializer rebuilds gitHost on demand, so clearing here is safe.
|
||||
state.gitHost = null;
|
||||
// Best-effort broadcast — receiver may not exist yet.
|
||||
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
|
||||
});
|
||||
@@ -73,7 +76,10 @@ chrome.commands.onCommand.addListener((command) => {
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||||
(async () => {
|
||||
if (!CONTENT_CALLABLE_TYPES.has(request.type as never)) {
|
||||
// Plan C Phase 5: invert the reset rule. Reset on every message
|
||||
// except a documented passive-read exclusion set, so an active
|
||||
// autofiller / content-driven flow keeps the vault alive.
|
||||
if (!READ_ONLY_CONTENT_CALLABLE.has(request.type)) {
|
||||
sessionTimer.resetTimer();
|
||||
}
|
||||
if (!state.wasm) {
|
||||
|
||||
@@ -378,18 +378,18 @@ describe('setup tab exception scope', () => {
|
||||
|
||||
// --- register_this_device: wasm returns a JS object, not a JSON string ---
|
||||
//
|
||||
// The #[wasm_bindgen] binding for `generate_device_keypair` uses
|
||||
// `serde-wasm-bindgen` and returns a plain JsValue (object), not a JSON
|
||||
// string. Calling JSON.parse on it throws `SyntaxError: "[object Object]"
|
||||
// is not valid JSON`. This regression test pins the contract.
|
||||
// The #[wasm_bindgen] binding for `register_device` uses `serde-wasm-bindgen`
|
||||
// and returns a plain JsValue (object), not a JSON string. Calling
|
||||
// JSON.parse on it would throw `SyntaxError: "[object Object]" is not
|
||||
// valid JSON`. This regression test pins that contract.
|
||||
|
||||
describe('register_this_device', () => {
|
||||
it('treats generate_device_keypair() as an object, not a JSON string', async () => {
|
||||
it('treats register_device() return value as an object, not a JSON string', async () => {
|
||||
const state = makeState();
|
||||
state.gitHost = {} as never;
|
||||
state.wasm.generate_device_keypair = () => ({
|
||||
public_key_hex: 'aa'.repeat(32),
|
||||
private_key_base64: 'AAAA',
|
||||
state.wasm.register_device = () => ({
|
||||
signing_public_key: 'aa'.repeat(32),
|
||||
deploy_public_key: 'bb'.repeat(32),
|
||||
});
|
||||
|
||||
vi.mocked(devices.addDevice).mockClear();
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { ContentMessage, Response } from '../../shared/messages';
|
||||
import type { Item, Manifest } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
import { itemToManifestEntry } from '../vault';
|
||||
import * as session from '../session';
|
||||
import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
|
||||
|
||||
export interface ContentState {
|
||||
manifest: Manifest | null;
|
||||
@@ -164,41 +166,6 @@ export async function handle(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation (duplicated from popup-only for self-containment) ---
|
||||
|
||||
function itemToManifestEntry(item: Item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
|
||||
?? { captureEnabled: false, captureStyle: 'bar' };
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { base32Decode } from '../../shared/base32';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
import { itemToManifestEntry } from '../vault';
|
||||
import * as session from '../session';
|
||||
import * as devices from '../devices';
|
||||
import * as sessionTimer from '../session-timer';
|
||||
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
|
||||
|
||||
// --- Shared ambient state owned by the SW module ---
|
||||
//
|
||||
@@ -58,6 +59,9 @@ export async function handle(
|
||||
case 'lock':
|
||||
session.clearCurrent();
|
||||
state.manifest = null;
|
||||
// Don't leak the cached git-host (incl. lastSyncAt) across a lock —
|
||||
// symmetric with the session-expiry path (index.ts); unlock rebuilds it.
|
||||
state.gitHost = null;
|
||||
return { ok: true };
|
||||
|
||||
case 'list_items': {
|
||||
@@ -129,6 +133,8 @@ export async function handle(
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||||
// Record sync time (unix SECONDS) for the get_vault_status indicator.
|
||||
state.gitHost.lastSyncAt = Math.floor(Date.now() / 1000);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -626,6 +632,18 @@ export async function handle(
|
||||
return { ok: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'create_vault':
|
||||
return vault.handleCreateVault(msg, state);
|
||||
|
||||
case 'attach_vault':
|
||||
return vault.handleAttachVault(msg, state);
|
||||
|
||||
case 'get_vault_status':
|
||||
return vault.handleGetVaultStatus(state);
|
||||
|
||||
default:
|
||||
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,44 +702,6 @@ async function loadSetupState(): Promise<SetupState> {
|
||||
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
||||
}
|
||||
|
||||
async function loadDeviceSettings(): Promise<DeviceSettings> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||||
}
|
||||
|
||||
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: s });
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
|
||||
|
||||
function itemToManifestEntry(item: Item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
|
||||
@@ -48,3 +48,17 @@ export function stopTimer(): void {
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-callable message types that should NOT reset the inactivity timer.
|
||||
*
|
||||
* Rationale: a content script reading available autofill candidates is a
|
||||
* passive query — it shouldn't keep the vault alive indefinitely while the
|
||||
* user isn't actually interacting with it.
|
||||
*
|
||||
* Today this is the only known passive read; if a future content message
|
||||
* is also passive, add it here with a one-line justification.
|
||||
*/
|
||||
export const READ_ONLY_CONTENT_CALLABLE: ReadonlySet<string> = new Set([
|
||||
'get_autofill_candidates',
|
||||
]);
|
||||
|
||||
25
extension/src/service-worker/storage.ts
Normal file
25
extension/src/service-worker/storage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/// Single home for chrome.storage.local reads/writes done by the service
|
||||
/// worker. Both router files (popup-only.ts and content-callable.ts) import
|
||||
/// from here — the duplicated definitions in those files were lifted out as
|
||||
/// part of Plan C Phase 2 (P1.9).
|
||||
|
||||
import type { DeviceSettings } from '../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types';
|
||||
|
||||
export async function loadDeviceSettings(): Promise<DeviceSettings> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||||
}
|
||||
|
||||
export async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: s });
|
||||
}
|
||||
|
||||
export async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
export async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||
import type { GitHost } from './git-host';
|
||||
import { uint8ArrayToBase64 } from './git-host';
|
||||
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
|
||||
import { createGitHost, uint8ArrayToBase64 } from './git-host';
|
||||
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types';
|
||||
import * as session from './session';
|
||||
import * as devices from './devices';
|
||||
import type { AttachVaultResponse, CreateVaultResponse, GetVaultStatusResponse } from '../shared/messages';
|
||||
import type { PopupState } from './router/popup-only';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let wasm: any = null;
|
||||
@@ -17,6 +21,125 @@ function requireWasm(): any {
|
||||
return wasm;
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
|
||||
|
||||
/// Register this device on the remote (devices.json) and persist the vault
|
||||
/// config + reference image locally so future unlocks work. Shared by the
|
||||
/// create and attach flows — both finish with this identical tail.
|
||||
async function registerDeviceAndPersistConfig(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
w: any,
|
||||
git: GitHost,
|
||||
config: VaultConfig,
|
||||
referenceImageBytes: Uint8Array,
|
||||
deviceName: string,
|
||||
): Promise<void> {
|
||||
const keys = w.register_device(deviceName) as { signing_public_key: string };
|
||||
await devices.addDevice(git, {
|
||||
name: deviceName,
|
||||
public_key: keys.signing_public_key,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
await chrome.storage.local.set({
|
||||
vaultConfig: config,
|
||||
imageBase64: uint8ArrayToBase64(referenceImageBytes),
|
||||
device_name: deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCreateVault(
|
||||
msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string },
|
||||
state: PopupState,
|
||||
): Promise<CreateVaultResponse | { ok: false; error: string }> {
|
||||
const w = state.wasm;
|
||||
let handle: SessionHandle | null = null;
|
||||
try {
|
||||
const carrierBytes = new Uint8Array(msg.carrierImageBytes);
|
||||
const imageSecret = new Uint8Array(32);
|
||||
crypto.getRandomValues(imageSecret);
|
||||
const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));
|
||||
|
||||
const salt = new Uint8Array(32);
|
||||
crypto.getRandomValues(salt);
|
||||
// Capture the unlock result in a non-null binding for the in-scope ops;
|
||||
// `handle` stays the ownership tracker the finally block cleans up.
|
||||
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON);
|
||||
handle = h;
|
||||
|
||||
const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}'));
|
||||
const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json()));
|
||||
|
||||
const { config } = msg;
|
||||
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt');
|
||||
await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters');
|
||||
await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest');
|
||||
await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings');
|
||||
|
||||
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
|
||||
|
||||
// SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable).
|
||||
session.setCurrent(h);
|
||||
state.gitHost = git;
|
||||
state.manifest = { schema_version: 2, items: {} } as Manifest;
|
||||
handle = null; // ownership transferred — do NOT lock-and-free in finally
|
||||
|
||||
return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
// Plan A .free() policy (docs/...extension-restructure-design.md Risks): lock THEN free,
|
||||
// and only if we still own the handle (success path transfers ownership to session.setCurrent).
|
||||
if (handle) {
|
||||
try { w.lock(handle); } catch { /* lock may already have happened */ }
|
||||
handle.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAttachVault(
|
||||
msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string },
|
||||
state: PopupState,
|
||||
): Promise<AttachVaultResponse | { ok: false; error: string }> {
|
||||
const w = state.wasm;
|
||||
let handle: SessionHandle | null = null;
|
||||
try {
|
||||
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes);
|
||||
const { config } = msg;
|
||||
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
|
||||
// The vault metadata and manifest are independent read-only GETs — fan out.
|
||||
const [meta, encryptedManifest] = await Promise.all([
|
||||
fetchVaultMeta(git),
|
||||
git.readFile('manifest.enc'),
|
||||
]);
|
||||
|
||||
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson);
|
||||
handle = h;
|
||||
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
|
||||
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
|
||||
|
||||
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
|
||||
|
||||
// SW now owns the unlocked session — transfer ownership to the session.
|
||||
session.setCurrent(h);
|
||||
state.gitHost = git;
|
||||
state.manifest = manifest;
|
||||
handle = null; // ownership transferred — do NOT lock-and-free in finally
|
||||
|
||||
return { ok: true, data: { deviceName: msg.deviceName } };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
// Same .free() policy as handleCreateVault: lock THEN free, only if we still
|
||||
// own the handle (success path transfers ownership to session.setCurrent).
|
||||
if (handle) {
|
||||
try { w.lock(handle); } catch { /* lock may already have happened */ }
|
||||
handle.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface VaultMeta {
|
||||
salt: Uint8Array;
|
||||
paramsJson: string;
|
||||
@@ -395,3 +518,62 @@ export async function removeAttachmentsFromItem(
|
||||
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
|
||||
return removed;
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation ---
|
||||
|
||||
/**
|
||||
* Project a decrypted Item into its ManifestEntry shape for browse-without-
|
||||
* decrypt views. Both router files use this; defined here (the SW's
|
||||
* vault-orchestration home) instead of duplicated in each router. Moved out
|
||||
* of popup-only.ts / content-callable.ts as part of Plan C Phase 2 (P1.9).
|
||||
*/
|
||||
export function itemToManifestEntry(item: Item): ManifestEntry {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
|
||||
// --- Vault status (Plan C Phase 6) ---
|
||||
|
||||
/**
|
||||
* Return the cached vault status for the sidebar indicator. Reads cached sync
|
||||
* metadata off the GitHost (populated by the `sync` handler) plus a live count
|
||||
* of active (non-trashed) items from the in-memory manifest. Does NOT touch
|
||||
* the network — sync is user-initiated (spec 2026-05-04, Phase 6). The
|
||||
* Pick-typed gitHost param both avoids a circular import of the router's
|
||||
* PopupState and structurally forbids a network call from here.
|
||||
*/
|
||||
export function handleGetVaultStatus(
|
||||
state: {
|
||||
gitHost: Pick<GitHost, 'lastSyncAt' | 'ahead' | 'behind'> | null;
|
||||
manifest: Manifest | null;
|
||||
},
|
||||
): GetVaultStatusResponse | { ok: false; error: string } {
|
||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const pendingItems = state.manifest ? listItems(state.manifest).length : 0;
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
ahead: state.gitHost.ahead,
|
||||
behind: state.gitHost.behind,
|
||||
lastSyncAt: state.gitHost.lastSyncAt,
|
||||
pendingItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { finishSetup } from '../setup';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { finishSetup, STEPS } from '../setup';
|
||||
import { state, clearWizardState } from '../setup-steps';
|
||||
|
||||
describe('finishSetup', () => {
|
||||
beforeEach(() => {
|
||||
@@ -35,3 +36,47 @@ describe('finishSetup', () => {
|
||||
expect(chrome.tabs.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup step registry', () => {
|
||||
it('has the six steps in canonical order', () => {
|
||||
expect(STEPS.map((s) => s.id)).toEqual(['mode', 'host', 'connection', 'vault', 'device', 'done']);
|
||||
});
|
||||
|
||||
it('each step renders non-empty HTML and attach returns a teardown', () => {
|
||||
const ctx = { state: {} as never, rerender: vi.fn(), goto: vi.fn() };
|
||||
for (const step of STEPS) {
|
||||
const html = step.render(ctx as never);
|
||||
expect(typeof html).toBe('string');
|
||||
expect(html.length).toBeGreaterThan(0);
|
||||
// render output must be in the DOM before attach (attach wires getElementById listeners)
|
||||
document.body.innerHTML = `<div id="app">${html}</div>`;
|
||||
const teardown = step.attach(document.body, ctx as never);
|
||||
expect(typeof teardown).toBe('function');
|
||||
teardown(); // must not throw
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearWizardState', () => {
|
||||
afterEach(() => {
|
||||
clearWizardState();
|
||||
});
|
||||
|
||||
it('zero-fills the reachable Uint8Array fields and resets state', () => {
|
||||
const carrier = new Uint8Array([1, 2, 3, 4]);
|
||||
const ref = new Uint8Array([5, 6, 7, 8]);
|
||||
state.carrierImageBytes = carrier;
|
||||
state.referenceImageBytes = ref;
|
||||
state.passphrase = 'secret';
|
||||
state.mode = 'new';
|
||||
|
||||
clearWizardState();
|
||||
|
||||
expect(Array.from(carrier)).toEqual([0, 0, 0, 0]); // fill(0) ran on the captured ref
|
||||
expect(Array.from(ref)).toEqual([0, 0, 0, 0]);
|
||||
expect(state.carrierImageBytes).toBeNull(); // field reset
|
||||
expect(state.referenceImageBytes).toBeNull();
|
||||
expect(state.passphrase).toBe('');
|
||||
expect(state.mode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
805
extension/src/setup/setup-steps.ts
Normal file
805
extension/src/setup/setup-steps.ts
Normal file
@@ -0,0 +1,805 @@
|
||||
import { createGitHost } from '../service-worker/git-host';
|
||||
import { probeVault } from './probe';
|
||||
import type { VaultProbe } from './probe';
|
||||
import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText } from './setup-helpers';
|
||||
import { GLYPH_NEXT } from '../shared/glyphs';
|
||||
import type { VaultConfig } from '../shared/types';
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
|
||||
// --- SW messaging ---
|
||||
|
||||
export function swSend(msg: Request): Promise<Response> {
|
||||
return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r)));
|
||||
}
|
||||
|
||||
// --- Step registry types ---
|
||||
|
||||
export type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done';
|
||||
|
||||
export interface StepContext {
|
||||
state: WizardState;
|
||||
rerender: () => void;
|
||||
goto: (id: StepId) => void;
|
||||
}
|
||||
|
||||
export interface SetupStep {
|
||||
id: StepId;
|
||||
render: (ctx: StepContext) => string;
|
||||
attach: (root: HTMLElement, ctx: StepContext) => () => void;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
export interface WizardState {
|
||||
stepId: StepId;
|
||||
mode: 'new' | 'attach' | null;
|
||||
hostType: 'gitea' | 'github';
|
||||
hostUrl: string;
|
||||
repoPath: string;
|
||||
apiToken: string;
|
||||
connectionTested: boolean;
|
||||
vaultProbe: VaultProbe | null;
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
referenceImageBytesAttach: Uint8Array | null;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
passphraseScore: number;
|
||||
passphraseGuessesLog10: number;
|
||||
passphraseVisible: boolean;
|
||||
confirmVisible: boolean;
|
||||
referenceImageBytes: Uint8Array | null;
|
||||
creating: boolean;
|
||||
attaching: boolean;
|
||||
error: string | null;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
export const state: WizardState = {
|
||||
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
|
||||
connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null,
|
||||
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
|
||||
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
|
||||
creating: false, attaching: false, error: null, deviceName: '',
|
||||
};
|
||||
|
||||
// --- State-coupled helpers ---
|
||||
|
||||
function updateStrengthUi(): void {
|
||||
const bar = document.getElementById('strength-bar');
|
||||
const label = document.getElementById('strength-label');
|
||||
const entropy = document.getElementById('entropy-line');
|
||||
const counter = document.getElementById('passphrase-counter');
|
||||
const matchInd = document.getElementById('match-indicator');
|
||||
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
||||
const score = state.passphraseScore;
|
||||
|
||||
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
||||
if (label) {
|
||||
if (score < 0) { label.className = 'strength-label'; label.innerHTML = ' '; }
|
||||
else {
|
||||
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
|
||||
label.className = `strength-label ${meta.cls}`;
|
||||
label.textContent = meta.text;
|
||||
}
|
||||
}
|
||||
if (entropy) {
|
||||
const txt = entropyText(state.passphraseGuessesLog10);
|
||||
entropy.textContent = txt;
|
||||
entropy.style.visibility = txt ? 'visible' : 'hidden';
|
||||
}
|
||||
if (counter) {
|
||||
const n = state.passphrase.length;
|
||||
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (matchInd) {
|
||||
const p = state.passphrase, c = state.passphraseConfirm;
|
||||
if (!p || !c) { matchInd.className = 'match-indicator'; matchInd.textContent = ''; }
|
||||
else if (p === c) { matchInd.className = 'match-indicator ok'; matchInd.textContent = '✓'; }
|
||||
else { matchInd.className = 'match-indicator bad'; matchInd.textContent = '✗'; }
|
||||
}
|
||||
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
|
||||
if (create) {
|
||||
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
|
||||
create.disabled = disabled;
|
||||
create.title = disabled
|
||||
? (score < 3 ? 'passphrase must score "good" or better'
|
||||
: !state.passphraseConfirm ? 'confirm your passphrase'
|
||||
: !matchOk ? 'passphrases do not match' : '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function vaultConfig(): VaultConfig {
|
||||
return {
|
||||
hostType: state.hostType,
|
||||
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
|
||||
repoPath: state.repoPath,
|
||||
apiToken: state.apiToken,
|
||||
};
|
||||
}
|
||||
|
||||
// --- mode ---
|
||||
|
||||
const modeStep: SetupStep = {
|
||||
id: 'mode',
|
||||
render() {
|
||||
const isNew = state.mode === 'new';
|
||||
const isAttach = state.mode === 'attach';
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>set up Relicario</h3>
|
||||
<p class="muted" style="margin-bottom:16px;">How are you using Relicario on this device?</p>
|
||||
<div class="mode-cards">
|
||||
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
|
||||
<span class="mode-card__icon" style="font-size:28px;">◈</span>
|
||||
<div class="mode-card-title">create new vault</div>
|
||||
<p class="mode-card-blurb">I'm setting up Relicario for the first time. This will create a fresh encrypted vault on a new or empty git repository.</p>
|
||||
</button>
|
||||
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
|
||||
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
|
||||
<div class="mode-card-title">attach this device</div>
|
||||
<p class="mode-card-blurb">I already have a vault on another device. Connect this browser to it using my passphrase and reference image.</p>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:24px;">
|
||||
<button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.querySelectorAll('.mode-card').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach';
|
||||
ctx.rerender();
|
||||
});
|
||||
});
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||
if (state.mode) ctx.goto('host');
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- host ---
|
||||
|
||||
const GITEA_INSTRUCTIONS = `
|
||||
<div class="step-instructions"><ol>
|
||||
<li>Create a new <strong>private</strong> repository on your Gitea instance (e.g. <code>vault</code>)</li>
|
||||
<li>Go to <strong>Settings → Applications</strong></li>
|
||||
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
|
||||
<li>Copy the token — you will need it in the next step</li>
|
||||
</ol></div>`;
|
||||
|
||||
const GITHUB_INSTRUCTIONS = `
|
||||
<div class="step-instructions"><ol>
|
||||
<li>Create a new <strong>private</strong> repository on GitHub (e.g. <code>vault</code>)</li>
|
||||
<li>Go to <strong>Settings → Developer settings → Personal access tokens → Fine-grained tokens</strong></li>
|
||||
<li>Generate a new token scoped to the vault repo with <strong>Contents</strong> read/write permission</li>
|
||||
<li>Copy the token — you will need it in the next step</li>
|
||||
</ol></div>`;
|
||||
|
||||
const hostStep: SetupStep = {
|
||||
id: 'host',
|
||||
render() {
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>choose host</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">host type</label>
|
||||
<div class="toggle-group">
|
||||
<button class="${state.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
|
||||
<button class="${state.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS}
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('mode'));
|
||||
document.querySelectorAll('.toggle-group button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
|
||||
state.connectionTested = false;
|
||||
ctx.rerender();
|
||||
});
|
||||
});
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => ctx.goto('connection'));
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- connection ---
|
||||
|
||||
function renderProbeBanner(): string {
|
||||
const probe = state.vaultProbe;
|
||||
if (!state.connectionTested || !probe) return '';
|
||||
const meta = probe.lastCommit
|
||||
? `Last commit: <code>${escapeHtml(probe.lastCommit.sha)}</code> by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.`
|
||||
: '';
|
||||
if (state.mode === 'new' && probe.exists) {
|
||||
return `
|
||||
<div class="banner banner-warn">
|
||||
<strong>⚠ This repository already contains a Relicario vault.</strong>
|
||||
<p>${meta}</p>
|
||||
<p>Creating a new vault here would overwrite the existing one and <strong>destroy all data inside</strong>. To use this vault on this device, switch to <em>attach</em> mode instead. If you really mean to start over, delete the repository via your git host's web UI and come back here.</p>
|
||||
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="attach">switch to attach mode</button></div>
|
||||
</div>`;
|
||||
}
|
||||
if (state.mode === 'attach' && !probe.exists) {
|
||||
return `
|
||||
<div class="banner banner-warn">
|
||||
<strong>No vault found in this repo.</strong>
|
||||
<p>Did you mean to create a new vault?</p>
|
||||
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="new">switch to new-vault mode</button></div>
|
||||
</div>`;
|
||||
}
|
||||
if (state.mode === 'attach' && probe.exists) {
|
||||
return `
|
||||
<div class="banner banner-ok">
|
||||
<strong>✓ Existing vault found.</strong>
|
||||
<p>${meta}</p>
|
||||
<p>Continue to attach this device.</p>
|
||||
</div>`;
|
||||
}
|
||||
// mode = new, !exists
|
||||
return `<div class="banner banner-ok"><strong>✓ Repo is empty — ready to create a new vault.</strong></div>`;
|
||||
}
|
||||
|
||||
const connectionStep: SetupStep = {
|
||||
id: 'connection',
|
||||
render() {
|
||||
const probe = state.vaultProbe;
|
||||
const modeMismatch =
|
||||
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
|
||||
const nextDisabled = !state.connectionTested || !probe || modeMismatch;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>configure connection</h3>
|
||||
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
|
||||
<label class="label" for="host-url">host url</label>
|
||||
<input id="host-url" type="text" value="${escapeHtml(state.hostUrl)}" placeholder="https://git.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="repo-path">repository path</label>
|
||||
<input id="repo-path" type="text" value="${escapeHtml(state.repoPath)}" placeholder="user/vault">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="api-token">api token</label>
|
||||
<input id="api-token" type="password" value="${escapeHtml(state.apiToken)}" placeholder="paste your token here">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="test-btn">test connection</button>
|
||||
${state.connectionTested ? '<span class="test-result pass">connected</span>' : ''}
|
||||
</div>
|
||||
${renderProbeBanner()}
|
||||
<div class="form-actions" style="margin-top:12px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.getElementById('test-btn')?.addEventListener('click', async () => {
|
||||
state.connectionTested = false;
|
||||
state.vaultProbe = null;
|
||||
const hostUrl = state.hostType === 'github'
|
||||
? 'https://api.github.com'
|
||||
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
|
||||
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
|
||||
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
|
||||
|
||||
if (!repoPath || !apiToken) {
|
||||
state.error = 'Repository path and API token are required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (state.hostType === 'gitea' && !hostUrl) {
|
||||
state.error = 'Host URL is required for Gitea';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
state.hostUrl = hostUrl;
|
||||
state.repoPath = repoPath;
|
||||
state.apiToken = apiToken;
|
||||
try {
|
||||
const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken);
|
||||
await host.listDir('');
|
||||
state.connectionTested = true;
|
||||
state.error = null;
|
||||
try {
|
||||
state.vaultProbe = await probeVault(host);
|
||||
} catch (probeErr) {
|
||||
state.vaultProbe = null;
|
||||
state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
state.connectionTested = false;
|
||||
state.vaultProbe = null;
|
||||
state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
ctx.rerender();
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('host'));
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||
if (state.connectionTested) ctx.goto('vault');
|
||||
});
|
||||
document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => {
|
||||
state.mode = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach';
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- vault ---
|
||||
|
||||
function renderVaultAttach(): string {
|
||||
const p = state.passphrase;
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const hasImage = !!state.referenceImageBytesAttach;
|
||||
const gateDisabled = !p || !hasImage;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>attach this device</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.</p>
|
||||
<div class="form-group">
|
||||
<label class="label">reference image (JPEG)</label>
|
||||
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
|
||||
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
|
||||
${hasImage ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter your passphrase" autocomplete="current-password">
|
||||
<button type="button" class="eye-btn" id="eye-btn">${pToggle}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="attach-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderVaultNew(): string {
|
||||
const score = state.passphraseScore;
|
||||
const hasScore = score >= 0;
|
||||
const meterClass = hasScore ? `s${score}` : '';
|
||||
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
|
||||
const labelClass = labelMeta?.cls ?? '';
|
||||
const labelText = labelMeta?.text ?? ' ';
|
||||
const entropy = entropyText(state.passphraseGuessesLog10);
|
||||
const p = state.passphrase, c = state.passphraseConfirm;
|
||||
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
|
||||
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const cType = state.confirmVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const cToggle = state.confirmVisible ? 'hide' : 'show';
|
||||
const matchOk = !c || p === c;
|
||||
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
|
||||
const nChars = p.length;
|
||||
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>create vault</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">carrier image (JPEG)</label>
|
||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
||||
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
|
||||
${state.carrierImageBytes ? '<p class="secondary">image loaded</p>' : '<p class="secondary">click to select a JPEG photo</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
||||
</div>
|
||||
<div class="pass-help">A long phrase of unrelated words is stronger than a short complex password. Your vault needs <strong>good</strong> (score ≥ 3) to continue.</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
||||
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
|
||||
</div>
|
||||
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
||||
<div class="seg i0"></div><div class="seg i1"></div><div class="seg i2"></div><div class="seg i3"></div><div class="seg i4"></div>
|
||||
</div>
|
||||
<div class="strength-row">
|
||||
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
||||
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
|
||||
</div>
|
||||
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
||||
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
|
||||
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const vaultStep: SetupStep = {
|
||||
id: 'vault',
|
||||
render(ctx) {
|
||||
return ctx.state.mode === 'attach' ? renderVaultAttach() : renderVaultNew();
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
return state.mode === 'attach' ? attachVaultAttach(ctx) : attachVaultNew(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
function attachVaultAttach(ctx: StepContext): () => void {
|
||||
const refDrop = document.getElementById('ref-drop')!;
|
||||
const refInput = document.getElementById('ref-input') as HTMLInputElement;
|
||||
refDrop.addEventListener('click', () => refInput.click());
|
||||
refInput.addEventListener('change', () => {
|
||||
const file = refInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
const btn = document.getElementById('attach-btn') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach;
|
||||
});
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('eye-btn');
|
||||
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||
passInput?.focus();
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
|
||||
document.getElementById('attach-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytesAttach) {
|
||||
state.error = 'Please select your reference JPEG image';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (!state.passphrase) {
|
||||
state.error = 'Passphrase is required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
ctx.goto('device');
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function attachVaultNew(ctx: StepContext): () => void {
|
||||
const fileDrop = document.getElementById('file-drop')!;
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
fileDrop.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
// Track passphrase changes inline (no full re-render) so the input keeps focus.
|
||||
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
updateStrengthUi();
|
||||
scheduleRate(state.passphrase, (s) => {
|
||||
state.passphraseScore = s.score;
|
||||
state.passphraseGuessesLog10 = s.guessesLog10;
|
||||
updateStrengthUi();
|
||||
});
|
||||
});
|
||||
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
|
||||
confirmInput?.addEventListener('input', (e) => {
|
||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||
updateStrengthUi();
|
||||
});
|
||||
// Eye toggles — flip the input type and label without a full re-render so
|
||||
// focus + cursor position survive the click.
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('eye-btn');
|
||||
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||
passInput?.focus();
|
||||
});
|
||||
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
|
||||
state.confirmVisible = !state.confirmVisible;
|
||||
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('confirm-eye-btn');
|
||||
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
|
||||
confirmInput?.focus();
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
|
||||
document.getElementById('create-btn')?.addEventListener('click', async () => {
|
||||
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
|
||||
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
|
||||
if (!state.carrierImageBytes) {
|
||||
state.error = 'Please select a carrier JPEG image';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (!state.passphrase) {
|
||||
state.error = 'Passphrase is required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
// Re-rate synchronously in case the button was clicked before the debounced
|
||||
// rater fired. Defence in depth — the button is already disabled when score < 3.
|
||||
const strength = await ratePassphrase(state.passphrase);
|
||||
state.passphraseScore = strength.score;
|
||||
state.passphraseGuessesLog10 = strength.guessesLog10;
|
||||
if (state.passphraseScore < 3) {
|
||||
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
if (state.passphrase !== state.passphraseConfirm) {
|
||||
state.error = 'Passphrases do not match';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
ctx.goto('device');
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// --- device ---
|
||||
|
||||
const deviceStep: SetupStep = {
|
||||
id: 'device',
|
||||
render() {
|
||||
const busy = state.creating || state.attaching;
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent);
|
||||
const isFirefox = /firefox/i.test(navigator.userAgent);
|
||||
const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser';
|
||||
const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux';
|
||||
const defaultName = state.deviceName || `${browser} on ${os}`;
|
||||
const busyLabel = state.attaching ? 'attaching…' : 'creating…';
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>name this device</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">This helps you identify which devices have access to your vault.</p>
|
||||
<div class="form-group">
|
||||
<label class="label" for="device-name">device name</label>
|
||||
<input id="device-name" type="text" value="${escapeHtml(defaultName)}" placeholder="e.g. Chrome on Linux" ${busy ? 'disabled' : ''}>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn" ${busy ? 'disabled' : ''}>back</button>
|
||||
<button class="btn-primary" id="next-btn" ${busy ? 'disabled' : ''}>${busy ? `<span class="spinner"></span> ${busyLabel}` : `continue ${GLYPH_NEXT}`}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, ctx) {
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
if (!state.creating && !state.attaching) ctx.goto('vault');
|
||||
});
|
||||
document.getElementById('next-btn')?.addEventListener('click', async () => {
|
||||
if (state.creating || state.attaching) return;
|
||||
const name = (document.getElementById('device-name') as HTMLInputElement).value.trim();
|
||||
if (!name) {
|
||||
state.error = 'Device name is required';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
state.deviceName = name;
|
||||
state.error = null;
|
||||
if (state.mode === 'attach') {
|
||||
state.attaching = true;
|
||||
ctx.rerender();
|
||||
const resp = await swSend({
|
||||
type: 'attach_vault',
|
||||
config: vaultConfig(),
|
||||
passphrase: state.passphrase,
|
||||
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
|
||||
deviceName: state.deviceName,
|
||||
});
|
||||
state.attaching = false;
|
||||
if (resp.ok) ctx.goto('done');
|
||||
else { state.error = resp.error; ctx.rerender(); }
|
||||
} else {
|
||||
state.creating = true;
|
||||
ctx.rerender();
|
||||
const resp = await swSend({
|
||||
type: 'create_vault',
|
||||
config: vaultConfig(),
|
||||
passphrase: state.passphrase,
|
||||
carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer,
|
||||
deviceName: state.deviceName,
|
||||
});
|
||||
state.creating = false;
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { referenceImageBytes: Uint8Array };
|
||||
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
|
||||
ctx.goto('done');
|
||||
} else { state.error = resp.error; ctx.rerender(); }
|
||||
}
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- done ---
|
||||
|
||||
const doneStep: SetupStep = {
|
||||
id: 'done',
|
||||
render() {
|
||||
const isAttach = state.mode === 'attach';
|
||||
const qrBannerHtml = isAttach ? '' : `
|
||||
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
|
||||
<div class="recovery-qr-banner__header">
|
||||
<span style="font-size:20px;">◫</span>
|
||||
<strong>Generate a recovery QR before you go</strong>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;margin:4px 0 8px;">If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.</p>
|
||||
<div class="recovery-qr-banner__actions">
|
||||
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
|
||||
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const refSection = isAttach ? '' : `
|
||||
<div class="form-group">
|
||||
<label class="label">reference image</label>
|
||||
<p class="muted" style="margin-bottom:8px;">Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.</p>
|
||||
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<div class="success-box">
|
||||
<h3>${isAttach ? 'device attached' : 'vault created'}</h3>
|
||||
<p class="secondary">${isAttach ? 'This device is now attached to your vault.' : 'Your vault has been initialized and pushed to the repository.'}</p>
|
||||
</div>
|
||||
${qrBannerHtml}
|
||||
${refSection}
|
||||
<div class="form-group" style="margin-top:16px;">
|
||||
<label class="label">extension configuration</label>
|
||||
<p class="muted" style="margin-bottom:8px;">
|
||||
Copy this JSON to configure Relicario on another setup, or save it for later.
|
||||
</p>
|
||||
<div class="config-blob" id="config-blob">${escapeHtml(JSON.stringify(vaultConfig(), null, 2))}</div>
|
||||
<button class="btn" id="copy-config-btn">copy to clipboard</button>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:16px;">
|
||||
<button class="btn btn-primary" id="open-vault-btn">open vault</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
attach(_root, _ctx) {
|
||||
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
|
||||
try {
|
||||
const resp = await swSend({ type: 'generate_recovery_qr', passphrase: state.passphrase });
|
||||
if (!resp.ok || !resp.data) throw new Error(resp.ok ? 'unknown error' : resp.error);
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
|
||||
});
|
||||
const banner = document.getElementById('recovery-qr-banner');
|
||||
if (banner) {
|
||||
banner.innerHTML = `
|
||||
<div style="text-align:center;">${svg}</div>
|
||||
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">◉ Recovery QR generated — save or print this now.</p>
|
||||
<div style="margin-top:8px;"><button class="btn" id="setup-qr-done">Done</button></div>`;
|
||||
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
|
||||
banner.style.display = 'none';
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
|
||||
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
|
||||
const banner = document.getElementById('recovery-qr-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
});
|
||||
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytes) return;
|
||||
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'reference.jpg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
|
||||
const blob = document.getElementById('config-blob');
|
||||
if (!blob) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(blob.textContent ?? '');
|
||||
const btn = document.getElementById('copy-config-btn')!;
|
||||
btn.textContent = 'copied!';
|
||||
setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000);
|
||||
} catch {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(blob);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
});
|
||||
document.getElementById('open-vault-btn')?.addEventListener('click', () => void finishSetup());
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
export const STEPS: ReadonlyArray<SetupStep> = [
|
||||
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
|
||||
];
|
||||
|
||||
// --- Sensitive-state cleanup ---
|
||||
|
||||
export function clearWizardState(): void {
|
||||
// Best-effort wipe — JS strings are GC-only (see spec Risks); zero-fill the Uint8Arrays.
|
||||
state.carrierImageBytes?.fill(0);
|
||||
state.referenceImageBytes?.fill(0);
|
||||
state.referenceImageBytesAttach?.fill(0);
|
||||
state.mode = null;
|
||||
state.hostType = 'gitea';
|
||||
state.hostUrl = '';
|
||||
state.repoPath = '';
|
||||
state.apiToken = '';
|
||||
state.connectionTested = false;
|
||||
state.vaultProbe = null;
|
||||
state.carrierImageBytes = null;
|
||||
state.referenceImageBytesAttach = null;
|
||||
state.passphrase = '';
|
||||
state.passphraseConfirm = '';
|
||||
state.passphraseScore = -1;
|
||||
state.passphraseGuessesLog10 = -1;
|
||||
state.passphraseVisible = false;
|
||||
state.confirmVisible = false;
|
||||
state.referenceImageBytes = null;
|
||||
state.creating = false;
|
||||
state.attaching = false;
|
||||
state.error = null;
|
||||
state.deviceName = '';
|
||||
}
|
||||
|
||||
// --- Completion handoff ---
|
||||
|
||||
/// Open the fullscreen vault tab and best-effort close the setup tab.
|
||||
export async function finishSetup(): Promise<void> {
|
||||
const vaultUrl = chrome.runtime.getURL('vault.html');
|
||||
await chrome.tabs.create({ url: vaultUrl });
|
||||
try {
|
||||
const current = await chrome.tabs.getCurrent();
|
||||
if (current?.id !== undefined) {
|
||||
await chrome.tabs.remove(current.id);
|
||||
}
|
||||
} catch {
|
||||
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
|
||||
// The vault tab is open — that's the user-visible success.
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,3 +68,17 @@ describe('Stream A glyphs (vault tab + type icons)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('management-surface glyphs', () => {
|
||||
it('exposes a history glyph', () => {
|
||||
expect(glyphs.GLYPH_HISTORY).toBe('◷');
|
||||
});
|
||||
|
||||
it('exposes a revoke glyph distinct from reveal/hide semantics', () => {
|
||||
expect(glyphs.GLYPH_REVOKE).toBe('⊘');
|
||||
});
|
||||
|
||||
it('exposes a restore glyph for trash actions', () => {
|
||||
expect(glyphs.GLYPH_RESTORE).toBe('⤺');
|
||||
});
|
||||
});
|
||||
|
||||
46
extension/src/shared/__tests__/relative-time.test.ts
Normal file
46
extension/src/shared/__tests__/relative-time.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { relativeTime, daysUntilPurge } from '../relative-time';
|
||||
|
||||
const NOW_UNIX = 1779552000; // fixed reference instant
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(NOW_UNIX * 1000));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('returns "just now" under 60s', () => {
|
||||
expect(relativeTime(NOW_UNIX - 30)).toBe('just now');
|
||||
});
|
||||
it('returns minutes under an hour', () => {
|
||||
expect(relativeTime(NOW_UNIX - 600)).toBe('10m ago');
|
||||
});
|
||||
it('returns hours under a day', () => {
|
||||
expect(relativeTime(NOW_UNIX - 7200)).toBe('2h ago');
|
||||
});
|
||||
it('returns days under a week', () => {
|
||||
expect(relativeTime(NOW_UNIX - 3 * 86400)).toBe('3d ago');
|
||||
});
|
||||
it('returns weeks under a month', () => {
|
||||
expect(relativeTime(NOW_UNIX - 14 * 86400)).toBe('2w ago');
|
||||
});
|
||||
it('returns months above 30 days', () => {
|
||||
expect(relativeTime(NOW_UNIX - 90 * 86400)).toBe('3mo ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysUntilPurge', () => {
|
||||
it('returns null for forever retention', () => {
|
||||
expect(daysUntilPurge(NOW_UNIX - 5 * 86400, { kind: 'forever' })).toBeNull();
|
||||
});
|
||||
it('returns remaining days for a recent trash', () => {
|
||||
expect(daysUntilPurge(NOW_UNIX - 8 * 86400, { kind: 'days', value: 30 })).toBe(22);
|
||||
});
|
||||
it('clamps to zero when retention already elapsed', () => {
|
||||
expect(daysUntilPurge(NOW_UNIX - 60 * 86400, { kind: 'days', value: 30 })).toBe(0);
|
||||
});
|
||||
});
|
||||
30
extension/src/shared/__tests__/ssh-fingerprint.test.ts
Normal file
30
extension/src/shared/__tests__/ssh-fingerprint.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sshFingerprint } from '../ssh-fingerprint';
|
||||
|
||||
describe('sshFingerprint', () => {
|
||||
it('formats a known ed25519 key to SHA256:<b64>', async () => {
|
||||
// Public key for the seed below — same format `relicario device list` prints.
|
||||
// Pre-computed: SHA256 of the base64-decoded key blob, base64-no-pad encoded.
|
||||
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ alice@example';
|
||||
const fp = await sshFingerprint(key);
|
||||
expect(fp).toMatch(/^SHA256:[A-Za-z0-9+/]+$/);
|
||||
expect(fp?.includes('=')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for malformed input', async () => {
|
||||
expect(await sshFingerprint('')).toBeNull();
|
||||
expect(await sshFingerprint('not a key')).toBeNull();
|
||||
expect(await sshFingerprint('ssh-ed25519')).toBeNull(); // missing blob
|
||||
});
|
||||
|
||||
it('returns null for invalid base64', async () => {
|
||||
expect(await sshFingerprint('ssh-ed25519 !!!notbase64!!!')).toBeNull();
|
||||
});
|
||||
|
||||
it('is deterministic for the same key', async () => {
|
||||
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ';
|
||||
const a = await sshFingerprint(key);
|
||||
const b = await sshFingerprint(key);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
66
extension/src/shared/__tests__/state-vault-locked.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerHost, __resetHostForTests, sendMessage } from '../state';
|
||||
import type { StateHost } from '../state';
|
||||
import type { Response } from '../messages';
|
||||
|
||||
function makeHost(response: { ok: boolean; error?: string }): StateHost {
|
||||
return {
|
||||
getState: () => ({ view: 'list' } as never),
|
||||
setState: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue(response as Response),
|
||||
escapeHtml: (s) => s,
|
||||
popOutToTab: vi.fn(),
|
||||
isInTab: () => false,
|
||||
openVaultTab: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('shared/state sendMessage vault_locked intercept', () => {
|
||||
beforeEach(() => __resetHostForTests());
|
||||
|
||||
it('navigates to the lock screen on a vault_locked response', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).toHaveBeenCalledWith(
|
||||
'locked',
|
||||
expect.objectContaining({ error: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT intercept the unlock request itself', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'unlock', passphrase: 'x' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT intercept is_unlocked', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'is_unlocked' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not intercept a successful response', async () => {
|
||||
const host = makeHost({ ok: true });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not intercept a non-vault_locked error', async () => {
|
||||
const host = makeHost({ ok: false, error: 'something_else' });
|
||||
registerHost(host);
|
||||
await sendMessage({ type: 'list_items' });
|
||||
expect(host.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the response unchanged', async () => {
|
||||
const host = makeHost({ ok: false, error: 'vault_locked' });
|
||||
registerHost(host);
|
||||
const resp = await sendMessage({ type: 'list_items' });
|
||||
expect(resp).toEqual({ ok: false, error: 'vault_locked' });
|
||||
});
|
||||
});
|
||||
92
extension/src/shared/__tests__/state.test.ts
Normal file
92
extension/src/shared/__tests__/state.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
registerHost,
|
||||
__resetHostForTests,
|
||||
getState,
|
||||
setState,
|
||||
navigate,
|
||||
sendMessage,
|
||||
} from '../state';
|
||||
import type { StateHost } from '../state';
|
||||
import type { PopupState } from '../popup-state';
|
||||
|
||||
function makeHost(initial?: Partial<PopupState>): StateHost {
|
||||
let state: PopupState = {
|
||||
view: 'list',
|
||||
entries: [],
|
||||
selectedId: null,
|
||||
selectedItem: null,
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
capturedTabId: null,
|
||||
capturedUrl: '',
|
||||
newType: null,
|
||||
vaultSettings: null,
|
||||
generatorDefaults: null,
|
||||
historyItemId: null,
|
||||
...initial,
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
setState: (partial) => { state = { ...state, ...partial }; },
|
||||
navigate: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue({ ok: true }),
|
||||
escapeHtml: (s) => s,
|
||||
popOutToTab: vi.fn(),
|
||||
isInTab: () => false,
|
||||
openVaultTab: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('shared/state', () => {
|
||||
beforeEach(() => {
|
||||
__resetHostForTests();
|
||||
});
|
||||
|
||||
it('register-then-getState round-trips', () => {
|
||||
const host = makeHost({ view: 'detail' });
|
||||
registerHost(host);
|
||||
expect(getState().view).toBe('detail');
|
||||
});
|
||||
|
||||
it('double-register throws', () => {
|
||||
registerHost(makeHost());
|
||||
expect(() => registerHost(makeHost())).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it('__resetHostForTests clears the singleton', () => {
|
||||
registerHost(makeHost());
|
||||
__resetHostForTests();
|
||||
expect(() => getState()).toThrow(/No state host/);
|
||||
});
|
||||
|
||||
it('getState without host throws', () => {
|
||||
expect(() => getState()).toThrow(/No state host/);
|
||||
});
|
||||
|
||||
it('setState merges partial state', () => {
|
||||
const host = makeHost();
|
||||
registerHost(host);
|
||||
setState({ loading: true });
|
||||
expect(getState().loading).toBe(true);
|
||||
});
|
||||
|
||||
it('navigate delegates to host', () => {
|
||||
const host = makeHost();
|
||||
registerHost(host);
|
||||
navigate('settings');
|
||||
expect(host.navigate).toHaveBeenCalledWith('settings', undefined);
|
||||
});
|
||||
|
||||
it('sendMessage delegates to host', async () => {
|
||||
const host = makeHost();
|
||||
registerHost(host);
|
||||
const resp = await sendMessage({ type: 'is_unlocked' });
|
||||
expect(host.sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' });
|
||||
expect(resp).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,17 @@ export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
||||
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
||||
export const GLYPH_COPY = '⎘'; // copy to clipboard
|
||||
export const GLYPH_SYNC = '⇅'; // sync / upload
|
||||
export const GLYPH_REFRESH = '↻'; // manual refresh (vault status indicator); shares ↻ with GENERATE, distinct semantic
|
||||
export const GLYPH_SYNCED = '✓'; // vault status: in sync (no pending/ahead/behind)
|
||||
export const GLYPH_AHEAD = '↑'; // vault status: local commits ahead of remote
|
||||
export const GLYPH_BEHIND = '↓'; // vault status: remote commits not yet pulled
|
||||
export const GLYPH_PENDING = '◌'; // vault status: items changed but not yet synced
|
||||
export const GLYPH_PREVIEW = '⊕'; // preview / expand
|
||||
|
||||
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
|
||||
export const GLYPH_HISTORY = '◷'; // sidebar history nav (clock-quadrant — distinct from clock emoji)
|
||||
export const GLYPH_REVOKE = '⊘'; // revoke device / autofill-origin ack (same shape as HIDE; kept distinct for semantic clarity)
|
||||
export const GLYPH_RESTORE = '⤺'; // restore from trash
|
||||
|
||||
export const GLYPH_TYPE_LOGIN = '◉'; // login
|
||||
export const GLYPH_TYPE_SECURE_NOTE = '◫'; // secure note
|
||||
|
||||
@@ -63,7 +63,12 @@ export type PopupMessage =
|
||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string }
|
||||
| { type: 'generate_recovery_qr'; passphrase: string }
|
||||
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
|
||||
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string }
|
||||
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
|
||||
carrierImageBytes: ArrayBuffer; deviceName: string }
|
||||
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
|
||||
referenceImageBytes: ArrayBuffer; deviceName: string }
|
||||
| { type: 'get_vault_status' };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
@@ -176,6 +181,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||
'preview_totp_from_secret',
|
||||
'generate_recovery_qr', 'unwrap_recovery_qr',
|
||||
'create_vault', 'attach_vault', 'get_vault_status',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||
@@ -201,6 +207,20 @@ export interface ImportLastPassCommitResponse extends Extract<Response, { ok: tr
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateVaultResponse extends Extract<Response, { ok: true }> {
|
||||
data: { referenceImageBytes: Uint8Array; deviceName: string;
|
||||
recoveryQrAvailable: true };
|
||||
}
|
||||
|
||||
export interface AttachVaultResponse extends Extract<Response, { ok: true }> {
|
||||
data: { deviceName: string };
|
||||
}
|
||||
|
||||
export interface GetVaultStatusResponse extends Extract<Response, { ok: true }> {
|
||||
data: { ahead: number; behind: number; lastSyncAt: number | null;
|
||||
pendingItems: number };
|
||||
}
|
||||
|
||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
||||
'capture_save_login',
|
||||
|
||||
57
extension/src/shared/popup-state.ts
Normal file
57
extension/src/shared/popup-state.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// State shared between popup and vault surfaces. Kept here (not in popup/) so
|
||||
// shared/state.ts can import without creating a popup→shared circular dep.
|
||||
|
||||
import type {
|
||||
Item,
|
||||
ItemId,
|
||||
ItemType,
|
||||
ManifestEntry,
|
||||
GeneratorRequest,
|
||||
VaultSettings,
|
||||
} from './types';
|
||||
|
||||
export type View =
|
||||
| 'locked'
|
||||
| 'list'
|
||||
| 'detail'
|
||||
| 'add'
|
||||
| 'edit'
|
||||
| 'settings'
|
||||
| 'settings-vault'
|
||||
| 'trash'
|
||||
| 'devices'
|
||||
| 'field-history'
|
||||
// Vault-tab-only views; popup never navigates to these. Kept in the union so
|
||||
// a single typed StateHost contract serves both surfaces (popup + vault).
|
||||
| 'history'
|
||||
| 'backup'
|
||||
| 'import';
|
||||
|
||||
export interface PopupState {
|
||||
view: View;
|
||||
entries: Array<[ItemId, ManifestEntry]>;
|
||||
selectedId: ItemId | null;
|
||||
selectedItem: Item | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
// Captured tab snapshot taken at popup-open. Used by fill_credentials
|
||||
// to guard against TOCTOU navigation — the SW re-checks this URL's
|
||||
// hostname against the tab's live URL before forwarding fill_credentials
|
||||
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
newType: ItemType | null;
|
||||
vaultSettings: VaultSettings | null;
|
||||
generatorDefaults: GeneratorRequest | null;
|
||||
historyItemId: ItemId | null;
|
||||
// Vault-tab-only fields. The popup surface leaves these at their defaults
|
||||
// (unlocked=false implicit via separate lock-screen view, drawer/panel false).
|
||||
// Kept on the shared shape so VaultState satisfies StateHost.getState()
|
||||
// without a cast.
|
||||
unlocked?: boolean;
|
||||
drawerOpen?: boolean;
|
||||
typePanelOpen?: boolean;
|
||||
}
|
||||
27
extension/src/shared/relative-time.ts
Normal file
27
extension/src/shared/relative-time.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/// Single source of truth for relative-time formatting and trash-retention math.
|
||||
/// Pulled out of five near-duplicate inline copies (settings-vault, devices,
|
||||
/// trash, field-history, vault/vault).
|
||||
|
||||
import type { TrashRetention } from './types';
|
||||
|
||||
/// Format a past unix timestamp (seconds) as "Nm ago" / "Nh ago" / "Nd ago" /
|
||||
/// "Nw ago" / "Nmo ago" relative to now. Returns "just now" under 60 seconds.
|
||||
export function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - unixSec;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
|
||||
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||
}
|
||||
|
||||
/// Days remaining until an item trashed at `trashedAt` (unix seconds) will be
|
||||
/// auto-purged given the vault's retention policy. Returns null for forever
|
||||
/// retention; clamps to 0 when the retention window has already elapsed.
|
||||
export function daysUntilPurge(trashedAt: number, retention: TrashRetention): number | null {
|
||||
if (retention.kind === 'forever') return null;
|
||||
const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400);
|
||||
return Math.max(0, retention.value - trashedDaysAgo);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user