Compare commits
63 Commits
v0.6.0
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743a46f3d5 | ||
|
|
409ddce049 | ||
|
|
631608e6e5 | ||
|
|
ca4936cf95 | ||
|
|
da4dc44f80 | ||
|
|
f249395644 | ||
|
|
b655024320 | ||
|
|
8c19e3cfda | ||
|
|
21ed8d83b8 | ||
|
|
ac6756e698 | ||
|
|
2543ed30f6 | ||
|
|
2a6f6f1307 | ||
|
|
108965ec84 | ||
|
|
7c7efa7c43 | ||
|
|
397cc78b86 | ||
|
|
675452a9ef | ||
|
|
f4b4cf3db7 | ||
|
|
c662db2875 | ||
|
|
5efc3a5491 | ||
|
|
61275574d4 | ||
|
|
3121431a7e | ||
|
|
3b8368db3a | ||
|
|
0c722b3a9d | ||
|
|
31913b8648 | ||
|
|
fecf58e54a | ||
|
|
7f076b49ac | ||
|
|
68cada5593 | ||
|
|
9049512e0d | ||
|
|
51255b3583 | ||
|
|
9df2fee295 | ||
|
|
eed48e2bbb | ||
|
|
8044310fba | ||
|
|
d300d62c60 | ||
|
|
bceb44f8af | ||
|
|
9fd5e33cd4 | ||
|
|
0befd4e629 | ||
|
|
e3d29c7d1b | ||
|
|
0e1e1a722d | ||
|
|
2cf74968e0 | ||
|
|
34d6155801 | ||
|
|
e3a1eefb50 | ||
|
|
a00a710e3b | ||
|
|
88b4176cc7 | ||
|
|
8d31fc5f45 | ||
|
|
042f1eb929 | ||
|
|
9fc07c3cd1 | ||
|
|
8249f9e3d3 | ||
|
|
0496dfe533 | ||
|
|
fce1962315 | ||
|
|
c3f8e3541c | ||
|
|
39fac68fc1 | ||
|
|
31ed5c0384 | ||
|
|
3f2e43753d | ||
|
|
547f2d4089 | ||
|
|
b6707f41f2 | ||
|
|
e43f121dfb | ||
|
|
20f074af20 | ||
|
|
35444e02be | ||
|
|
ba5d218841 | ||
|
|
f1621df3e2 | ||
|
|
4a1c553f9d | ||
|
|
39c86ab123 | ||
|
|
d717f0d4a1 |
694
.claude/workflows/release.js
Normal file
694
.claude/workflows/release.js
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'release',
|
||||||
|
description: 'Relicario release lifecycle: develop features (single/multi-agent), iterate on debug, cut releases',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Discover' },
|
||||||
|
{ title: 'Plan' },
|
||||||
|
{ title: 'Execute' },
|
||||||
|
{ title: 'Verify' },
|
||||||
|
{ title: 'Generate' },
|
||||||
|
{ title: 'Finalize' },
|
||||||
|
{ title: 'Cleanup' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MANIFEST_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
plans: { type: 'array', items: { type: 'string' } },
|
||||||
|
taskCount: { type: 'number' },
|
||||||
|
domains: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['plans', 'taskCount', 'domains'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_LIST_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tasks: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
planPath: { type: 'string' },
|
||||||
|
techDomain: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['id', 'description', 'planPath', 'techDomain'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['tasks'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSIGNMENT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
devCount: { type: 'number' },
|
||||||
|
devs: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
letter: { type: 'string' },
|
||||||
|
scope: { type: 'string' },
|
||||||
|
tasks: { type: 'array', items: { type: 'string' } },
|
||||||
|
outOfScope: { type: 'array', items: { type: 'string' } },
|
||||||
|
techDomain: { type: 'string' },
|
||||||
|
planFiles: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['letter', 'scope', 'tasks', 'outOfScope', 'techDomain', 'planFiles'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pmScope: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['devCount', 'devs', 'pmScope'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBUG_RESULT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
fixed: { type: 'boolean' },
|
||||||
|
summary: { type: 'string' },
|
||||||
|
remainingFailures: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['fixed', 'summary'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFY_RESULT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
allPass: { type: 'boolean' },
|
||||||
|
failures: { type: 'array', items: { type: 'string' } },
|
||||||
|
summary: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['allPass', 'failures', 'summary'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKTREE_STATUS_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
stale: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['stale', 'active'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_STATE_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tickedTasks: { type: 'number' },
|
||||||
|
totalTasks: { type: 'number' },
|
||||||
|
gitEvidence: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['tickedTasks', 'totalTasks', 'gitEvidence'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRANCH_CHECK_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collisions: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['collisions'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERSION_CHECK_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
consistent: { type: 'boolean' },
|
||||||
|
versions: { type: 'array', items: { type: 'string' } },
|
||||||
|
conflicts: { type: 'array', items: { type: 'string' } },
|
||||||
|
tagExists: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['consistent', 'versions', 'conflicts', 'tagExists'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLEANUP_RESULT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
removed: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kept: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
branch: { type: 'string' },
|
||||||
|
reason: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'branch', 'reason'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['removed', 'kept'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REPO = '/home/alee/Sources/relicario'
|
||||||
|
const COORD_DIR = 'docs/superpowers/coordination'
|
||||||
|
|
||||||
|
function devRole(letter) {
|
||||||
|
return 'dev-' + letter.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Support both object args {action, mode, release} and space-separated string
|
||||||
|
// "action mode release-label" (e.g. "develop multi enterprise-org-vault").
|
||||||
|
let _args = args
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
const parts = args.trim().split(/\s+/)
|
||||||
|
// "develop multi enterprise-org-vault" → 3 parts
|
||||||
|
// "develop enterprise-org-vault" → 2 parts (mode defaults to single)
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
_args = { action: parts[0], mode: parts[1], release: parts.slice(2).join(' ') }
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
_args = { action: parts[0], mode: 'single', release: parts[1] }
|
||||||
|
} else {
|
||||||
|
_args = { action: parts[0] || 'develop' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = (_args && _args.action) || 'develop'
|
||||||
|
const mode = (_args && _args.mode) || 'single'
|
||||||
|
const release = _args && _args.release
|
||||||
|
const context = _args && _args.context
|
||||||
|
|
||||||
|
// ── ACTION: preflight ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'preflight') {
|
||||||
|
if (!release) throw new Error('args.release is required for action=preflight')
|
||||||
|
|
||||||
|
const [worktrees, baseline, planState, branches] = await parallel([
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Run: git -C ${REPO} worktree list\n` +
|
||||||
|
`Parse the output. For each worktree listed, extract its path and branch.\n` +
|
||||||
|
`Skip the main checkout at ${REPO} itself.\n` +
|
||||||
|
`Then run: git -C ${REPO} branch --merged main\n` +
|
||||||
|
`A worktree is stale if its branch appears in the merged list. Otherwise it is active.\n` +
|
||||||
|
`Return stale (merged worktrees) and active (unmerged worktrees), each as an array of {path, branch}.`,
|
||||||
|
{ schema: WORKTREE_STATUS_SCHEMA, label: 'worktree-scan', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`cd ${REPO} and run each of these commands, capturing the last 5 lines of output:\n` +
|
||||||
|
` cargo test --quiet 2>&1 | tail -5\n` +
|
||||||
|
` pnpm --filter extension test --run 2>&1 | tail -5\n` +
|
||||||
|
`Report allPass=true only if both commands exit with code 0. ` +
|
||||||
|
`List any failures with their error messages. Provide a one-line summary.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'baseline-green', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Run: git -C ${REPO} log --oneline --all --grep="${release}" | head -20\n` +
|
||||||
|
`Capture the output as gitEvidence.\n` +
|
||||||
|
`Then scan ${REPO}/docs/superpowers/plans/ for any files whose filename contains "${release}".\n` +
|
||||||
|
`For each matching file, count lines matching "- \\[x\\]" (ticked) and "- \\[ \\]" (unticked).\n` +
|
||||||
|
`Sum across all matching files. Return tickedTasks, totalTasks, and gitEvidence (the git log lines).`,
|
||||||
|
{ schema: PLAN_STATE_SCHEMA, label: 'plan-state', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Run: git -C ${REPO} branch --all\n` +
|
||||||
|
`Return any branch names (local or remote) that contain the string "${release}" as collisions.`,
|
||||||
|
{ schema: BRANCH_CHECK_SCHEMA, label: 'branch-collision', phase: 'Discover' }
|
||||||
|
),
|
||||||
|
|
||||||
|
])
|
||||||
|
|
||||||
|
const issues = []
|
||||||
|
|
||||||
|
if (worktrees.stale.length > 0) {
|
||||||
|
issues.push('orphaned-worktrees')
|
||||||
|
log(`WARN [worktree-scan]: ${worktrees.stale.length} stale worktree(s) found — run action=cleanup to remove them`)
|
||||||
|
for (const w of worktrees.stale) {
|
||||||
|
log(` stale: ${w.path} (${w.branch})`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(`[worktree-scan]: clean`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseline.allPass) {
|
||||||
|
issues.push('baseline-failing')
|
||||||
|
log(`FAIL [baseline-green]: ${baseline.failures.length} failure(s): ${baseline.failures.join(' | ')}`)
|
||||||
|
} else {
|
||||||
|
log(`[baseline-green]: green`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planState.tickedTasks > 0) {
|
||||||
|
issues.push('plan-partially-done')
|
||||||
|
log(`WARN [plan-state]: ${planState.tickedTasks}/${planState.totalTasks} tasks already ticked`)
|
||||||
|
for (const e of planState.gitEvidence) {
|
||||||
|
log(` evidence: ${e}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(`[plan-state]: clean slate (0 ticked tasks)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branches.collisions.length > 0) {
|
||||||
|
issues.push('branch-collision')
|
||||||
|
log(`WARN [branch-collision]: branches already exist for release label "${release}": ${branches.collisions.join(', ')}`)
|
||||||
|
} else {
|
||||||
|
log(`[branch-collision]: no collisions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
log(`Preflight PASS`)
|
||||||
|
} else {
|
||||||
|
log(`Preflight has ${issues.length} warning(s): ${issues.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: issues.length === 0 ? 'pass' : 'warn', issues, worktrees, baseline, planState, branches }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: develop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'develop') {
|
||||||
|
if (!release) throw new Error('args.release is required for action=develop')
|
||||||
|
|
||||||
|
phase('Discover')
|
||||||
|
const manifest = await agent(
|
||||||
|
`Scan docs/superpowers/plans/ in ${REPO} for plan files belonging to release "${release}". ` +
|
||||||
|
`A plan file belongs if its filename contains the release label, or its opening lines reference it as its target release. ` +
|
||||||
|
`Read each matching file, count checkbox tasks (lines starting with - [ ]), and identify tech domains (rust, extension, docs, etc.). ` +
|
||||||
|
`Return: plans (relative paths from repo root), taskCount, domains.`,
|
||||||
|
{ schema: MANIFEST_SCHEMA, label: 'discover-plans', phase: 'Discover' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Found ${manifest.plans.length} plan(s), ${manifest.taskCount} tasks — domains: ${manifest.domains.join(', ')}`)
|
||||||
|
|
||||||
|
// ── SINGLE MODE ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (mode === 'single') {
|
||||||
|
|
||||||
|
phase('Plan')
|
||||||
|
const taskList = await agent(
|
||||||
|
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
|
||||||
|
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||||
|
`Extract every checkbox task (- [ ] items) and order them to respect dependencies ` +
|
||||||
|
`(e.g. core Rust changes before WASM/CLI consumers, schema changes before UI). ` +
|
||||||
|
`For each task return: id (short slug like S1-step2), description (full step text), ` +
|
||||||
|
`planPath (which file it came from), techDomain (rust/extension/docs/cli/wasm).`,
|
||||||
|
{ schema: TASK_LIST_SCHEMA, label: 'pm-plan', phase: 'Plan' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`PM ordered ${taskList.tasks.length} tasks for sequential execution`)
|
||||||
|
|
||||||
|
phase('Execute')
|
||||||
|
await pipeline(
|
||||||
|
taskList.tasks,
|
||||||
|
(task) => agent(
|
||||||
|
`You are a senior developer on the ${release} release of Relicario.\n` +
|
||||||
|
`Repo: ${REPO}\n\n` +
|
||||||
|
`IMPORTANT: cd into ${REPO} before any git or cargo commands.\n\n` +
|
||||||
|
`Your task (${task.id}): ${task.description}\n` +
|
||||||
|
`Plan file for full context: ${task.planPath}\n` +
|
||||||
|
`Tech domain: ${task.techDomain}\n\n` +
|
||||||
|
`Instructions:\n` +
|
||||||
|
`1. Read the plan file for context on this specific step.\n` +
|
||||||
|
`2. Implement ONLY this step — do not run ahead to the next one.\n` +
|
||||||
|
`3. Run the relevant tests after your change (cargo test -p <crate> for Rust; pnpm build for extension).\n` +
|
||||||
|
`4. Commit with a conventional commit message scoped to the change.\n` +
|
||||||
|
`5. Report: what you did, test result (pass/fail), any blockers.`,
|
||||||
|
{ label: task.id, phase: 'Execute' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Advisory: checkbox hygiene ───────────────────────────────────────────
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Read each of these plan files from ${REPO}:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||||
|
`Count any lines still matching "- [ ]" (unticked checkboxes). ` +
|
||||||
|
`Log each unticked item with its file and line text. ` +
|
||||||
|
`This is advisory only — report findings but do not block or fail.`,
|
||||||
|
{ label: 'checkbox-check', phase: 'Verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
phase('Verify')
|
||||||
|
const verifyResult = await agent(
|
||||||
|
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||||
|
`Commands:\n` +
|
||||||
|
` cargo test\n` +
|
||||||
|
` cargo build --all-targets\n` +
|
||||||
|
` cargo clippy -- -D warnings\n` +
|
||||||
|
`Report pass/fail for each command. List every failure with its error message.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'full-verify', phase: 'Verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!verifyResult.allPass) {
|
||||||
|
log(`Verify FAILED — ${verifyResult.failures.length} failure(s): ${verifyResult.failures.join(' | ')}`)
|
||||||
|
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
|
||||||
|
return { status: 'verify-failed', failures: verifyResult.failures, summary: verifyResult.summary }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advisory: debug artifact scan ────────────────────────────────────────
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Run the following command from ${REPO}:\n` +
|
||||||
|
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
|
||||||
|
`Examine lines beginning with "+" (additions) in the diff output.\n` +
|
||||||
|
`Report any occurrences of:\n` +
|
||||||
|
` - dbg!( in Rust files (warn)\n` +
|
||||||
|
` - console.log( in TypeScript files (warn)\n` +
|
||||||
|
` - TODO or FIXME anywhere (warn)\n` +
|
||||||
|
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
|
||||||
|
`Log each finding with its file and line. This is advisory only — do not block.`,
|
||||||
|
{ label: 'debug-artifact-scan', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
phase('Finalize')
|
||||||
|
await agent(
|
||||||
|
`Update ${REPO}/STATUS.md to reflect the ${release} work that just completed.\n` +
|
||||||
|
`Mark any in-flight items as landed. Set what is now in flight next.\n` +
|
||||||
|
`Commit the STATUS.md update with message "docs: update STATUS for ${release} develop pass".`,
|
||||||
|
{ label: 'update-status', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Single-mode develop complete. Run action=release when ready to tag.`)
|
||||||
|
return { status: 'complete', mode: 'single', release }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MULTI MODE ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
phase('Plan')
|
||||||
|
const assignment = await agent(
|
||||||
|
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
|
||||||
|
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
|
||||||
|
`Decide how many dev streams are needed (one per major domain or plan, max 3). ` +
|
||||||
|
`Minimize cross-dev dependencies. For each dev assign: ` +
|
||||||
|
`letter (A/B/C), scope summary (2 sentences), task IDs they own, ` +
|
||||||
|
`out-of-scope task IDs (owned by other devs), primary techDomain, and which planFiles they need to read. ` +
|
||||||
|
`Also write a 2-sentence pmScope describing your oversight and review duties.`,
|
||||||
|
{ schema: ASSIGNMENT_SCHEMA, label: 'pm-assign', phase: 'Plan' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`PM assigned ${assignment.devCount} dev stream(s)`)
|
||||||
|
|
||||||
|
phase('Generate')
|
||||||
|
const allRoles = ['pm', ...assignment.devs.map(d => devRole(d.letter))].join(', ')
|
||||||
|
|
||||||
|
await parallel([
|
||||||
|
|
||||||
|
() => agent(
|
||||||
|
`Write a self-contained PM kickoff prompt to ${REPO}/${COORD_DIR}/${release}-pm-prompt.md.\n\n` +
|
||||||
|
`Release: ${release}\n` +
|
||||||
|
`PM scope: ${assignment.pmScope}\n` +
|
||||||
|
`Plans: ${manifest.plans.join(', ')}\n` +
|
||||||
|
`Dev roster:\n${assignment.devs.map(d => ` Dev-${d.letter}: ${d.scope}`).join('\n')}\n\n` +
|
||||||
|
`The file must include these sections in order:\n` +
|
||||||
|
`1. Role header ("You are the PM for the ${release} release of Relicario.")\n` +
|
||||||
|
`2. Working directory: ${REPO}\n` +
|
||||||
|
`3. Required reading: CLAUDE.md, all plan files listed above\n` +
|
||||||
|
`4. Authority: approve scope changes, review dev PRs, write CHANGELOG entry, drive doc updates, tag release (with user approval only)\n` +
|
||||||
|
`5. Boundaries: write NO feature code; NO destructive ops without user confirmation\n` +
|
||||||
|
`6. Relay server section: localhost:7331, your from="pm", tools: post_message/read_messages/list_pending, recipients: ${allRoles}. Include Python shim fallback.\n` +
|
||||||
|
`7. Dev roster with each dev letter, branch name (feature/${release}-dev-X), worktree path (${REPO}.dev-x), and scope\n` +
|
||||||
|
`8. Coordination protocol: DIRECTIVE block format, RELEASE STATUS rollup format\n` +
|
||||||
|
`9. PR review procedure (gh pr view / gh pr diff)\n` +
|
||||||
|
`10. Pre-tag checklist (all tests pass, CHANGELOG written, STATUS.md updated, all dev PRs merged)\n` +
|
||||||
|
`11. First action: read all required files, emit a RELEASE STATUS block confirming context absorbed, then check all dev inboxes\n` +
|
||||||
|
`Make every section concrete — the receiving Claude has zero prior context.`,
|
||||||
|
{ label: 'gen-pm', phase: 'Generate' }
|
||||||
|
),
|
||||||
|
|
||||||
|
...assignment.devs.map((dev) => () => agent(
|
||||||
|
`Write a self-contained Dev-${dev.letter} kickoff prompt to ${REPO}/${COORD_DIR}/${release}-dev-${dev.letter.toLowerCase()}-prompt.md.\n\n` +
|
||||||
|
`Release: ${release}\n` +
|
||||||
|
`Dev-${dev.letter} scope: ${dev.scope}\n` +
|
||||||
|
`Tasks owned: ${dev.tasks.join(', ')}\n` +
|
||||||
|
`Out of scope: ${dev.outOfScope.join(', ')}\n` +
|
||||||
|
`Tech domain: ${dev.techDomain}\n` +
|
||||||
|
`Plan files: ${dev.planFiles.join(', ')}\n\n` +
|
||||||
|
`The file must include these sections in order:\n` +
|
||||||
|
`1. Role header ("You are Dev-${dev.letter} for the ${release} release of Relicario.")\n` +
|
||||||
|
`2. Worktree setup commands (run these FIRST before anything else):\n` +
|
||||||
|
` git -C ${REPO} worktree add ${REPO}.dev-${dev.letter.toLowerCase()} -b feature/${release}-dev-${dev.letter.toLowerCase()}\n` +
|
||||||
|
` cd ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
|
||||||
|
`3. Working directory after setup: ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
|
||||||
|
`4. CRITICAL subagent rule: every subagent prompt MUST start with "cd ${REPO}.dev-${dev.letter.toLowerCase()} &&" — never rely on working-directory headers alone\n` +
|
||||||
|
`5. Required reading: CLAUDE.md, ${dev.planFiles.join(', ')}\n` +
|
||||||
|
`6. Execution mode: use superpowers:subagent-driven-development\n` +
|
||||||
|
`7. Scope: in-scope tasks (${dev.tasks.join(', ')}), out-of-scope (${dev.outOfScope.join(', ')})\n` +
|
||||||
|
`8. Hard rules from the plan (copy any HIGH-severity or acceptance-test constraints verbatim)\n` +
|
||||||
|
`9. Relay: localhost:7331, your from="${devRole(dev.letter)}", call read_messages before each task, post status/questions to "pm". Recipients: ${allRoles}. Include Python shim fallback.\n` +
|
||||||
|
`10. STATUS UPDATE format: Task / Status (COMPLETE|IN-PROGRESS|BLOCKED) / Notes (what + why) / Next — print locally AND post to pm via relay\n` +
|
||||||
|
`11. Final test commands for ${dev.techDomain}\n` +
|
||||||
|
`12. PR procedure: gh pr create targeting main, title "feat(${release}): Dev-${dev.letter} — <scope>"\n` +
|
||||||
|
`13. First action: run worktree setup, emit STATUS UPDATE "setup complete", start Task 1`,
|
||||||
|
{ label: `gen-dev-${dev.letter.toLowerCase()}`, phase: 'Generate' }
|
||||||
|
)),
|
||||||
|
|
||||||
|
])
|
||||||
|
|
||||||
|
// Check relay, start if needed
|
||||||
|
await agent(
|
||||||
|
`Check if the relay server is running on localhost:7331 by running: ` +
|
||||||
|
`curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1 && echo running || echo stopped\n\n` +
|
||||||
|
`If the output is "stopped", start it: ` +
|
||||||
|
`nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
|
||||||
|
`Then poll curl -sf http://127.0.0.1:7331/sse --max-time 1 once per second for up to 10s. ` +
|
||||||
|
`Report "relay ready" or "relay failed to start (check /tmp/relay-${release}.log)".`,
|
||||||
|
{ label: 'relay-check', phase: 'Generate' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Write a bash launch script to ${REPO}/${COORD_DIR}/${release}-launch.sh.\n\n` +
|
||||||
|
`Header comment: # Auto-generated by release workflow — ${release}\n` +
|
||||||
|
`set -e\n\n` +
|
||||||
|
`Section 1 — Relay health check and auto-start:\n` +
|
||||||
|
` if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then\n` +
|
||||||
|
` echo "[relay] already running"\n` +
|
||||||
|
` else\n` +
|
||||||
|
` echo "[relay] starting..." && nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
|
||||||
|
` for i in $(seq 1 10); do sleep 1; curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1 && echo "[relay] ready" && break || true; [ $i -eq 10 ] && echo "[relay] ERROR — check /tmp/relay-${release}.log" && exit 1; done\n` +
|
||||||
|
` fi\n\n` +
|
||||||
|
`Section 2 — tmux session. Session name is the release label.\n` +
|
||||||
|
` If tmux session already exists, attach and exit.\n` +
|
||||||
|
` Otherwise create a new session, then for each role (pm + each dev letter) create a named window\n` +
|
||||||
|
` that runs: claude\n` +
|
||||||
|
` After creating windows, print a prompt-paste cheatsheet showing which file to paste in each window.\n` +
|
||||||
|
` Then attach to the session.\n\n` +
|
||||||
|
`Devs: ${assignment.devs.map(d => 'Dev-' + d.letter).join(', ')}\n` +
|
||||||
|
`Prompt files in ${COORD_DIR}:\n` +
|
||||||
|
` PM: ${release}-pm-prompt.md\n` +
|
||||||
|
assignment.devs.map(d => ` Dev-${d.letter}: ${release}-dev-${d.letter.toLowerCase()}-prompt.md`).join('\\n') + '\\n\\n' +
|
||||||
|
`After writing the file, run: chmod +x ${REPO}/${COORD_DIR}/${release}-launch.sh`,
|
||||||
|
{ label: 'gen-launch-script', phase: 'Generate' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Prompts + launch script ready in ${COORD_DIR}/`)
|
||||||
|
log(`Run: ${REPO}/${COORD_DIR}/${release}-launch.sh`)
|
||||||
|
log(`(starts relay if needed, opens tmux session, prompts you which file to paste in each window)`)
|
||||||
|
|
||||||
|
return { status: 'prompts-ready', devCount: assignment.devCount, coordDir: COORD_DIR }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: debug ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'debug') {
|
||||||
|
if (!context) throw new Error('args.context required for action=debug — describe the failure or paste test output')
|
||||||
|
|
||||||
|
let currentContext = context
|
||||||
|
const MAX_ITERATIONS = 5
|
||||||
|
|
||||||
|
for (let i = 1; i <= MAX_ITERATIONS; i++) {
|
||||||
|
phase(`Debug iteration ${i}`)
|
||||||
|
|
||||||
|
const result = await agent(
|
||||||
|
`You are debugging a failure in Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
|
||||||
|
`Failure context:\n${currentContext}\n\n` +
|
||||||
|
`Use systematic debugging:\n` +
|
||||||
|
`1. Form a specific hypothesis about the root cause.\n` +
|
||||||
|
`2. Read the relevant source files and tests.\n` +
|
||||||
|
`3. Implement the minimal fix — no unrelated changes.\n` +
|
||||||
|
`4. Run the failing test(s) to confirm they now pass.\n` +
|
||||||
|
`5. Run cargo test to confirm no regressions.\n` +
|
||||||
|
`6. Commit the fix if clean.\n\n` +
|
||||||
|
`Return fixed=true if all tests pass, fixed=false with remainingFailures if not.`,
|
||||||
|
{ schema: DEBUG_RESULT_SCHEMA, label: `debug-iter-${i}` }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Iteration ${i}: ${result.summary}`)
|
||||||
|
|
||||||
|
if (result.fixed) {
|
||||||
|
log(`Fixed after ${i} iteration(s).`)
|
||||||
|
return { status: 'fixed', iterations: i, summary: result.summary }
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContext = result.remainingFailures || currentContext
|
||||||
|
log(`Still failing — next iteration with updated context`)
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Reached max iterations (${MAX_ITERATIONS}). Manual intervention needed.`)
|
||||||
|
return { status: 'max-iterations', lastContext: currentContext }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: verify ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'verify') {
|
||||||
|
phase('Verify')
|
||||||
|
const result = await agent(
|
||||||
|
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||||
|
` cargo test\n` +
|
||||||
|
` cargo build --all-targets\n` +
|
||||||
|
` cargo clippy -- -D warnings\n` +
|
||||||
|
`Report pass/fail for each. List every failure with its error text.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.allPass) {
|
||||||
|
log(`All checks pass.`)
|
||||||
|
} else {
|
||||||
|
log(`FAILED: ${result.failures.join(' | ')}`)
|
||||||
|
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: release ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'release') {
|
||||||
|
if (!release) throw new Error('args.release is required for action=release')
|
||||||
|
|
||||||
|
phase('Verify')
|
||||||
|
const verifyResult = await agent(
|
||||||
|
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
|
||||||
|
` cargo test\n` +
|
||||||
|
` cargo build --all-targets\n` +
|
||||||
|
` cargo clippy -- -D warnings\n` +
|
||||||
|
`Report pass/fail. List failures.`,
|
||||||
|
{ schema: VERIFY_RESULT_SCHEMA, label: 'pre-release-verify' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!verifyResult.allPass) {
|
||||||
|
log(`Tests failing — cannot cut release. Fix first with action=debug.`)
|
||||||
|
return { status: 'blocked', failures: verifyResult.failures }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Version + tag checks ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const versionCheck = await agent(
|
||||||
|
`Read ${REPO}/Cargo.toml and all files matching ${REPO}/crates/*/Cargo.toml.\n` +
|
||||||
|
`For each file, extract the version field from the [package] section.\n` +
|
||||||
|
`Check whether all extracted versions are identical.\n` +
|
||||||
|
`Then run: git -C ${REPO} tag -l "${release}"\n` +
|
||||||
|
`Set tagExists=true if the output is non-empty (the tag already exists), false otherwise.\n` +
|
||||||
|
`Return consistent (true if all versions match), versions (list of all extracted version strings), ` +
|
||||||
|
`conflicts (list of "file: version" strings for any that differ from the majority), and tagExists.`,
|
||||||
|
{ schema: VERSION_CHECK_SCHEMA, label: 'version-tag-check', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!versionCheck.consistent) {
|
||||||
|
log(`FAIL [version-tag-check]: version mismatch across crates — ${versionCheck.conflicts.join(' | ')}`)
|
||||||
|
return { status: 'blocked', reason: 'version-mismatch' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionCheck.tagExists) {
|
||||||
|
log(`FAIL [version-tag-check]: tag "${release}" already exists — cannot re-tag`)
|
||||||
|
return { status: 'blocked', reason: 'tag-exists' }
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[version-tag-check]: Versions consistent (${versionCheck.versions[0]}), tag available`)
|
||||||
|
|
||||||
|
// ── Advisory: debug artifact scan ──────────────────────────────────────────
|
||||||
|
|
||||||
|
await agent(
|
||||||
|
`Run the following command from ${REPO}:\n` +
|
||||||
|
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
|
||||||
|
`Examine lines beginning with "+" (additions) in the diff output.\n` +
|
||||||
|
`Report any occurrences of:\n` +
|
||||||
|
` - dbg!( in Rust files (warn)\n` +
|
||||||
|
` - console.log( in TypeScript files (warn)\n` +
|
||||||
|
` - TODO or FIXME anywhere (warn)\n` +
|
||||||
|
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
|
||||||
|
`Log each finding with its file and line. This is advisory only — do not block.`,
|
||||||
|
{ label: 'debug-artifact-scan', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
phase('Finalize')
|
||||||
|
await agent(
|
||||||
|
`Cut the ${release} release for Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
|
||||||
|
`Steps (in order):\n` +
|
||||||
|
`1. Run: git log $(git describe --tags --abbrev=0)..HEAD --oneline\n` +
|
||||||
|
` Use that output to write a ${release} section in CHANGELOG.md — user-facing language, grouped by type.\n` +
|
||||||
|
`2. Update STATUS.md: mark ${release} as released, set what is next.\n` +
|
||||||
|
`3. Update ROADMAP.md: check off the ${release} milestone.\n` +
|
||||||
|
`4. Commit those doc updates: git commit -m "release: ${release}"\n` +
|
||||||
|
`5. Create annotated tag: git tag -a ${release} -m "Release ${release}"\n` +
|
||||||
|
`6. STOP. Print the tag SHA and the push command, then ask the user to confirm before pushing.\n` +
|
||||||
|
` Do NOT run git push or git push --tags without explicit user confirmation.`,
|
||||||
|
{ label: 'cut-release', phase: 'Finalize' }
|
||||||
|
)
|
||||||
|
|
||||||
|
return { status: 'tagged', release, note: 'Confirm and push manually.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ACTION: cleanup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (action === 'cleanup') {
|
||||||
|
phase('Cleanup')
|
||||||
|
|
||||||
|
const result = await agent(
|
||||||
|
`Run: git -C ${REPO} worktree list\n` +
|
||||||
|
`Run: git -C ${REPO} branch --merged main\n\n` +
|
||||||
|
`For each worktree listed (skip the main checkout at ${REPO} itself):\n` +
|
||||||
|
` - If its branch appears in the merged list:\n` +
|
||||||
|
` Run: git -C ${REPO} worktree remove --force <path>\n` +
|
||||||
|
` Run: git -C ${REPO} branch -d <branch> (lowercase -d only, never -D)\n` +
|
||||||
|
` Add to removed: [{path, branch}]\n` +
|
||||||
|
` - If its branch does NOT appear in the merged list:\n` +
|
||||||
|
` Add to kept: [{path, branch, reason: "unmerged"}]\n\n` +
|
||||||
|
`Return removed (worktrees that were deleted) and kept (worktrees that were left in place).`,
|
||||||
|
{ schema: CLEANUP_RESULT_SCHEMA, label: 'cleanup', phase: 'Cleanup' }
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`Cleanup removed ${result.removed.length} worktree(s):`)
|
||||||
|
for (const w of result.removed) {
|
||||||
|
log(` removed: ${w.path} (${w.branch})`)
|
||||||
|
}
|
||||||
|
log(`Cleanup kept ${result.kept.length} worktree(s):`)
|
||||||
|
for (const w of result.kept) {
|
||||||
|
log(` kept: ${w.path} (${w.branch}) — ${w.reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'done', removed: result.removed.length, kept: result.kept.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Unknown action: "${action}". Valid: develop, debug, verify, release, preflight, cleanup`)
|
||||||
|
return { status: 'error', action }
|
||||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -1,5 +1,69 @@
|
|||||||
# Changelog
|
# 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
|
## v0.6.0 — 2026-05-30
|
||||||
|
|
||||||
Rolls up four weeks of post-v0.5.0 work into one tag: the Phase 2B
|
Rolls up four weeks of post-v0.5.0 work into one tag: the Phase 2B
|
||||||
|
|||||||
31
CLAUDE.md
31
CLAUDE.md
@@ -88,7 +88,7 @@ Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
|
|||||||
|
|
||||||
## Planning & design specs
|
## Planning & design specs
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
Core references (read before touching crypto, data model, or architecture):
|
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-11-relicario-design.md` — threat model, entropy analysis, crypto pipeline, crate layout
|
||||||
@@ -97,6 +97,31 @@ Core references (read before touching crypto, data model, or architecture):
|
|||||||
|
|
||||||
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).
|
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
|
## Roadmap & status
|
||||||
|
|
||||||
Current in-flight work: `STATUS.md`. Full roadmap with release targets: `ROADMAP.md`. Wire format reference: `docs/FORMATS.md`.
|
Current in-flight work: `STATUS.md`. Full roadmap with release targets: `ROADMAP.md`. Wire format reference: `docs/FORMATS.md`.
|
||||||
@@ -129,3 +154,7 @@ Four rules to prevent the kind of drift the 2026-05-30 audits found:
|
|||||||
4. **Plan-state hygiene.** Plan checkboxes and `STATUS.md`/`ROADMAP.md` must reflect what's actually shipped. Two halves:
|
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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -2185,7 +2185,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2209,6 +2209,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
"url",
|
"url",
|
||||||
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zstd",
|
"zstd",
|
||||||
"zxcvbn",
|
"zxcvbn",
|
||||||
@@ -2231,7 +2232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
@@ -3709,6 +3710,18 @@ version = "0.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x25519-dalek"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"rand_core",
|
||||||
|
"serde",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
12
ROADMAP.md
12
ROADMAP.md
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
| Version | Highlights |
|
| 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.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-α/β₁/β₂) |
|
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
|
||||||
|
|
||||||
@@ -14,18 +15,13 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c
|
|||||||
|
|
||||||
## Up next
|
## Up next
|
||||||
|
|
||||||
All three are specced but have no implementation plan yet. Writing a plan is the first move on any of them.
|
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:
|
||||||
|
|
||||||
- **CLI restructure** — subcommand reorganization, interactive TUI mode
|
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||||
Spec: `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
|
|
||||||
- **Extension restructure** — bundle / message-routing cleanup
|
|
||||||
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
|
|
||||||
- **Security polish** — follow-up hardening from the architecture review
|
|
||||||
Spec: `docs/superpowers/specs/2026-05-04-security-polish-design.md`
|
|
||||||
|
|
||||||
## Medium-term
|
## Medium-term
|
||||||
|
|
||||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
_(promote here once specced)_
|
||||||
|
|
||||||
## Long-term / backlog
|
## Long-term / backlog
|
||||||
|
|
||||||
|
|||||||
27
STATUS.md
27
STATUS.md
@@ -5,7 +5,7 @@
|
|||||||
## Version
|
## 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.
|
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
|
||||||
**Active track:** picking the next initiative (CLI restructure / extension restructure / security polish all have specs, no plans yet)
|
**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
|
## What landed on main since the v0.5.0 version bump
|
||||||
|
|
||||||
@@ -98,6 +98,19 @@ Plan: `docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md
|
|||||||
- Item-history-index pane — top-level "items with history" list (`32e1632`)
|
- Item-history-index pane — top-level "items with history" list (`32e1632`)
|
||||||
- Sidebar slot wiring + `#history/<id>` route with `#field-history/<id>` legacy normalization (`88d7228`)
|
- 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)
|
### Doc-structure redesign (2026-05-30, complete)
|
||||||
|
|
||||||
Spec: `docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md`
|
Spec: `docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md`
|
||||||
@@ -124,10 +137,12 @@ Plan: `docs/superpowers/plans/2026-05-30-doc-structure-redesign.md` (all 37 sub-
|
|||||||
|
|
||||||
## Up next
|
## Up next
|
||||||
|
|
||||||
The "Up next" queue at v0.6.0 is the three 2026-05-04 architecture-review specs. Each is specced but has no implementation plan yet — writing a plan is the first move on any of them.
|
Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review specs:
|
||||||
|
|
||||||
1. **CLI restructure** (spec `2026-05-04-cli-restructure-design.md`) — subcommand reorganization + interactive TUI mode.
|
- **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.
|
||||||
2. **Extension restructure** (spec `2026-05-04-extension-restructure-design.md`) — bundle / message-routing cleanup.
|
- **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.
|
||||||
3. **Security polish** (spec `2026-05-04-security-polish-design.md`) — follow-up security hardening from the architecture review.
|
- **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.
|
||||||
|
|
||||||
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.5.0` entry, dated 2026-05-02 — predates the v0.5.1 train work and will be revised when the next tag cuts).
|
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,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "CLI for relicario password manager"
|
description = "CLI for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
|||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
let entry = super::resolve_query(&manifest, &query)?;
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
let item = vault.load_item(&entry.id)?;
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ pub fn cmd_list(
|
|||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
|
|
||||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||||
None => None,
|
None => None,
|
||||||
|
|||||||
@@ -142,7 +142,13 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
|||||||
///
|
///
|
||||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||||
/// not a correctness problem.
|
/// not a correctness problem.
|
||||||
pub fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
///
|
||||||
|
/// 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();
|
let mut set = std::collections::BTreeSet::<String>::new();
|
||||||
for entry in manifest.items.values() {
|
for entry in manifest.items.values() {
|
||||||
if let Some(g) = entry.group.as_ref() {
|
if let Some(g) = entry.group.as_ref() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Core library for relicario password manager"
|
description = "Core library for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
@@ -16,6 +16,7 @@ sha2 = "0.10"
|
|||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ pub use generators::{generate_passphrase, generate_password, rate_passphrase, va
|
|||||||
|
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
pub use vault::{
|
pub use vault::{
|
||||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings,
|
||||||
encrypt_item, encrypt_manifest, encrypt_settings,
|
encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod imgsecret;
|
pub mod imgsecret;
|
||||||
@@ -93,6 +93,13 @@ pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||||
|
|
||||||
|
pub mod org;
|
||||||
|
pub use org::{
|
||||||
|
generate_org_key, unwrap_org_key, wrap_org_key,
|
||||||
|
CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest,
|
||||||
|
OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole,
|
||||||
|
};
|
||||||
|
|
||||||
pub mod tar_safe;
|
pub mod tar_safe;
|
||||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||||
|
|
||||||
|
|||||||
494
crates/relicario-core/src/org.rs
Normal file
494
crates/relicario-core/src/org.rs
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
|
||||||
|
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
use crate::ids::ItemId;
|
||||||
|
use crate::item_types::ItemType;
|
||||||
|
|
||||||
|
// ── IDs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct OrgId(pub String);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct MemberId(pub String);
|
||||||
|
|
||||||
|
impl OrgId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemberId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemberId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum OrgRole {
|
||||||
|
Owner,
|
||||||
|
Admin,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgRole {
|
||||||
|
pub fn can_manage_members(&self) -> bool {
|
||||||
|
matches!(self, OrgRole::Owner | OrgRole::Admin)
|
||||||
|
}
|
||||||
|
pub fn can_manage_owners(&self) -> bool {
|
||||||
|
matches!(self, OrgRole::Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Members ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMember {
|
||||||
|
pub member_id: MemberId,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: OrgRole,
|
||||||
|
/// SSH public key string (openssh format: "ssh-ed25519 AAAA...")
|
||||||
|
pub ed25519_pubkey: String,
|
||||||
|
/// Collection slugs this member can access.
|
||||||
|
#[serde(default)]
|
||||||
|
pub collections: Vec<String>,
|
||||||
|
pub added_at: i64,
|
||||||
|
pub added_by: MemberId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMembers {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub members: Vec<OrgMember>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgMembers {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, members: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> {
|
||||||
|
self.members.iter().find(|m| &m.member_id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> {
|
||||||
|
self.members.iter_mut().find(|m| &m.member_id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
for m in &self.members {
|
||||||
|
if !m.member_id.is_valid() {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("invalid member_id: {}", m.member_id.0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgMembers {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collections ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CollectionDef {
|
||||||
|
pub slug: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub created_by: MemberId,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgCollections {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub collections: Vec<CollectionDef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgCollections {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, collections: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains_slug(&self, slug: &str) -> bool {
|
||||||
|
self.collections.iter().any(|c| c.slug == slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
for c in &self.collections {
|
||||||
|
if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("invalid collection slug: {:?}", c.slug)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgCollections {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org meta ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMeta {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub org_id: OrgId,
|
||||||
|
pub display_name: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgMeta {
|
||||||
|
pub fn new(display_name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: 1,
|
||||||
|
org_id: OrgId::new(),
|
||||||
|
display_name,
|
||||||
|
created_at: crate::time::now_unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org manifest ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgManifestEntry {
|
||||||
|
pub id: ItemId,
|
||||||
|
pub r#type: ItemType,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub modified: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
|
/// Collection this item belongs to.
|
||||||
|
pub collection: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrgManifest {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub entries: Vec<OrgManifestEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgManifest {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: 1, entries: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return only entries whose collection is in `member.collections`.
|
||||||
|
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
|
||||||
|
let granted: std::collections::HashSet<&str> =
|
||||||
|
member.collections.iter().map(|s| s.as_str()).collect();
|
||||||
|
Self {
|
||||||
|
schema_version: self.schema_version,
|
||||||
|
entries: self.entries.iter()
|
||||||
|
.filter(|e| granted.contains(e.collection.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrgManifest {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ───────────────────
|
||||||
|
|
||||||
|
/// Generate a random 256-bit org master key.
|
||||||
|
pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
OsRng.fill_bytes(key.as_mut());
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path).
|
||||||
|
fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret {
|
||||||
|
use sha2::{Digest, Sha512};
|
||||||
|
let h = Sha512::digest(seed.as_ref());
|
||||||
|
let mut scalar = [0u8; 32];
|
||||||
|
scalar.copy_from_slice(&h[..32]);
|
||||||
|
// RFC 7748 clamping
|
||||||
|
scalar[0] &= 248;
|
||||||
|
scalar[31] &= 127;
|
||||||
|
scalar[31] |= 64;
|
||||||
|
x25519_dalek::StaticSecret::from(scalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an OpenSSH ed25519 public key string and return its X25519 form.
|
||||||
|
fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result<x25519_dalek::PublicKey> {
|
||||||
|
use ssh_key::PublicKey;
|
||||||
|
let pk = PublicKey::from_openssh(openssh.trim())
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?;
|
||||||
|
let ed_bytes = pk.key_data().ed25519()
|
||||||
|
.ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))?
|
||||||
|
.0;
|
||||||
|
let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?;
|
||||||
|
Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key.
|
||||||
|
///
|
||||||
|
/// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
|
||||||
|
pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result<Vec<u8>> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use x25519_dalek::EphemeralSecret;
|
||||||
|
|
||||||
|
let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?;
|
||||||
|
|
||||||
|
let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng);
|
||||||
|
let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk);
|
||||||
|
|
||||||
|
let shared = ephemeral_sk.diffie_hellman(&recipient_pk);
|
||||||
|
|
||||||
|
// Domain-separated KDF. All intermediates carrying the DH secret are held in
|
||||||
|
// Zeroizing so they are wiped on drop (H6).
|
||||||
|
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||||
|
kdf_input.extend_from_slice(shared.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||||
|
|
||||||
|
// Copy the digest straight into a Zeroizing array. The GenericArray returned
|
||||||
|
// by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and
|
||||||
|
// not enabled here), so we move the bytes into an owned [u8; 32] whose own
|
||||||
|
// Zeroize impl wipes them on drop.
|
||||||
|
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||||
|
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||||
|
|
||||||
|
let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(32 + encrypted.len());
|
||||||
|
out.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
out.extend_from_slice(&encrypted);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed.
|
||||||
|
pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
// Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext)
|
||||||
|
if wrapped.len() < 32 + 41 {
|
||||||
|
return Err(RelicarioError::Format("wrapped key blob too short".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut eph_bytes = [0u8; 32];
|
||||||
|
eph_bytes.copy_from_slice(&wrapped[..32]);
|
||||||
|
let ephemeral_pk = x25519_dalek::PublicKey::from(eph_bytes);
|
||||||
|
let encrypted = &wrapped[32..];
|
||||||
|
|
||||||
|
let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed);
|
||||||
|
let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk);
|
||||||
|
|
||||||
|
let shared = recipient_sk.diffie_hellman(&ephemeral_pk);
|
||||||
|
|
||||||
|
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||||
|
kdf_input.extend_from_slice(shared.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||||
|
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||||
|
|
||||||
|
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||||
|
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||||
|
|
||||||
|
let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?);
|
||||||
|
if plaintext.len() != 32 {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
format!("unwrapped key has wrong length: {}", plaintext.len())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
key.copy_from_slice(&plaintext);
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_id_is_16_hex_chars() {
|
||||||
|
let id = MemberId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_ids_are_unique() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..1_000 {
|
||||||
|
assert!(seen.insert(MemberId::new().0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_id_is_16_hex_chars() {
|
||||||
|
let id = OrgId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_role_can_manage_members() {
|
||||||
|
assert!(OrgRole::Owner.can_manage_members());
|
||||||
|
assert!(OrgRole::Admin.can_manage_members());
|
||||||
|
assert!(!OrgRole::Member.can_manage_members());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_slug_validation_rejects_slash() {
|
||||||
|
let mut c = OrgCollections::new();
|
||||||
|
c.collections.push(CollectionDef {
|
||||||
|
slug: "bad/slug".into(),
|
||||||
|
display_name: "Bad".into(),
|
||||||
|
created_by: MemberId::new(),
|
||||||
|
created_at: 0,
|
||||||
|
});
|
||||||
|
assert!(c.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_for_member_restricts_collections() {
|
||||||
|
let mut manifest = OrgManifest::new();
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: crate::item_types::ItemType::SecureNote,
|
||||||
|
title: "A".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "prod".into(),
|
||||||
|
});
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: crate::item_types::ItemType::SecureNote,
|
||||||
|
title: "B".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "dev".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let member = OrgMember {
|
||||||
|
member_id: MemberId::new(),
|
||||||
|
display_name: "Alice".into(),
|
||||||
|
role: OrgRole::Member,
|
||||||
|
ed25519_pubkey: String::new(),
|
||||||
|
collections: vec!["prod".into()],
|
||||||
|
added_at: 0,
|
||||||
|
added_by: MemberId::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered = manifest.filter_for_member(&member);
|
||||||
|
assert_eq!(filtered.entries.len(), 1);
|
||||||
|
assert_eq!(filtered.entries[0].collection, "prod");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_org_key_is_32_bytes() {
|
||||||
|
let key = generate_org_key();
|
||||||
|
assert_eq!(key.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed
|
||||||
|
/// and expected X25519 public key are from ed25519-dalek's own reference
|
||||||
|
/// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a
|
||||||
|
/// HARD-CODED LITERAL — NOT recomputed by the production code path — so a
|
||||||
|
/// correlated cross-crate-version regression in the birational map (where
|
||||||
|
/// both our derivation and a naive re-derivation would drift together) is
|
||||||
|
/// still caught. If this test ever fails after a dep bump, the wrap/unwrap
|
||||||
|
/// keyspace changed and every existing `keys/<id>.enc` blob is invalidated.
|
||||||
|
#[test]
|
||||||
|
fn ed25519_to_x25519_pinned_rfc8032_vector() {
|
||||||
|
let seed: [u8; 32] =
|
||||||
|
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
// Derive the X25519 *public* key the same way wrap/unwrap derives the
|
||||||
|
// recipient's static secret from a seed.
|
||||||
|
let secret = ed25519_seed_to_x25519_secret(&seed);
|
||||||
|
let public = x25519_dalek::PublicKey::from(&secret);
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(public.as_bytes()),
|
||||||
|
"d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_unwrap_round_trip() {
|
||||||
|
// Generate an ed25519 keypair to act as the member's device key
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||||
|
let seed_zeroizing = Zeroizing::new(seed);
|
||||||
|
let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap");
|
||||||
|
|
||||||
|
assert_eq!(*org_key, *unwrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unwrap_with_wrong_seed_fails() {
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||||
|
|
||||||
|
let wrong_seed = Zeroizing::new([0xFFu8; 32]);
|
||||||
|
let result = unwrap_org_key(&wrapped, &wrong_seed);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use crate::crypto::{decrypt, encrypt};
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
use crate::manifest::Manifest;
|
use crate::manifest::Manifest;
|
||||||
|
use crate::org::OrgManifest;
|
||||||
use crate::settings::VaultSettings;
|
use crate::settings::VaultSettings;
|
||||||
|
|
||||||
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||||
@@ -52,6 +53,19 @@ pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> R
|
|||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||||
|
let json = serde_json::to_vec(manifest)?;
|
||||||
|
let plaintext = Zeroizing::new(json);
|
||||||
|
encrypt(org_key, plaintext.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_org_manifest(encrypted: &[u8], org_key: &Zeroizing<[u8; 32]>) -> Result<OrgManifest> {
|
||||||
|
let plaintext = decrypt(org_key, encrypted)?;
|
||||||
|
let plaintext = Zeroizing::new(plaintext);
|
||||||
|
let manifest: OrgManifest = serde_json::from_slice(&plaintext)?;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -87,4 +101,27 @@ mod tests {
|
|||||||
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
||||||
s.attachment_caps.per_attachment_max_bytes);
|
s.attachment_caps.per_attachment_max_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_manifest_round_trip() {
|
||||||
|
use crate::org::{OrgManifest, OrgManifestEntry};
|
||||||
|
use crate::ids::ItemId;
|
||||||
|
use crate::item_types::ItemType;
|
||||||
|
|
||||||
|
let mut m = OrgManifest::new();
|
||||||
|
m.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: ItemType::SecureNote,
|
||||||
|
title: "test".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "prod".into(),
|
||||||
|
});
|
||||||
|
let key = key();
|
||||||
|
let bytes = encrypt_org_manifest(&m, &key).unwrap();
|
||||||
|
let decoded = decrypt_org_manifest(&bytes, &key).unwrap();
|
||||||
|
assert_eq!(decoded.entries.len(), 1);
|
||||||
|
assert_eq!(decoded.entries[0].collection, "prod");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
crates/relicario-core/tests/org.rs
Normal file
120
crates/relicario-core/tests/org.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use relicario_core::{
|
||||||
|
generate_org_key, wrap_org_key, unwrap_org_key,
|
||||||
|
encrypt_org_manifest, decrypt_org_manifest,
|
||||||
|
OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole,
|
||||||
|
MemberId, ItemId,
|
||||||
|
};
|
||||||
|
use relicario_core::item_types::ItemType;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) {
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seed);
|
||||||
|
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||||
|
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||||
|
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||||
|
)
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.expect("openssh");
|
||||||
|
(Zeroizing::new(seed), pubkey_openssh)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_key_wrap_unwrap_round_trip() {
|
||||||
|
let (seed, pubkey) = make_member_keypair();
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap");
|
||||||
|
let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap");
|
||||||
|
assert_eq!(*org_key, *unwrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_member_cannot_decrypt_after_rotation() {
|
||||||
|
// Alice and Bob both get access
|
||||||
|
let (alice_seed, alice_pubkey) = make_member_keypair();
|
||||||
|
let (_bob_seed, bob_pubkey) = make_member_keypair();
|
||||||
|
|
||||||
|
let org_key = generate_org_key();
|
||||||
|
let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice");
|
||||||
|
let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob");
|
||||||
|
|
||||||
|
// Rotate: new key, only Bob gets re-wrapped
|
||||||
|
let new_org_key = generate_org_key();
|
||||||
|
let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new");
|
||||||
|
|
||||||
|
// Alice tries to use old org_key — she can still decrypt old items,
|
||||||
|
// but new_bob_wrapped was encrypted with new_org_key, not org_key.
|
||||||
|
// Verify: unwrapping new_bob_wrapped with Alice's seed fails.
|
||||||
|
let result = unwrap_org_key(&new_bob_wrapped, &alice_seed);
|
||||||
|
assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_manifest_filter_restricts_to_granted_collections() {
|
||||||
|
let mut manifest = OrgManifest::new();
|
||||||
|
for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] {
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: ItemType::SecureNote,
|
||||||
|
title: title.to_string(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: collection.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let member = OrgMember {
|
||||||
|
member_id: MemberId::new(),
|
||||||
|
display_name: "Alice".into(),
|
||||||
|
role: OrgRole::Member,
|
||||||
|
ed25519_pubkey: String::new(),
|
||||||
|
collections: vec!["prod".into()],
|
||||||
|
added_at: 0,
|
||||||
|
added_by: MemberId::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered = manifest.filter_for_member(&member);
|
||||||
|
assert_eq!(filtered.entries.len(), 2);
|
||||||
|
assert!(filtered.entries.iter().all(|e| e.collection == "prod"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_manifest_encrypt_decrypt_round_trip() {
|
||||||
|
let key = generate_org_key();
|
||||||
|
let mut manifest = OrgManifest::new();
|
||||||
|
manifest.entries.push(OrgManifestEntry {
|
||||||
|
id: ItemId::new(),
|
||||||
|
r#type: ItemType::Login,
|
||||||
|
title: "GitHub".into(),
|
||||||
|
tags: vec!["work".into()],
|
||||||
|
modified: 1748000000,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "eng-tools".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt");
|
||||||
|
let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt");
|
||||||
|
|
||||||
|
assert_eq!(decrypted.entries.len(), 1);
|
||||||
|
assert_eq!(decrypted.entries[0].title, "GitHub");
|
||||||
|
assert_eq!(decrypted.entries[0].collection, "eng-tools");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn members_validation_rejects_invalid_id() {
|
||||||
|
let mut members = OrgMembers::new();
|
||||||
|
members.members.push(OrgMember {
|
||||||
|
member_id: MemberId("not-hex-lol!!".to_string()),
|
||||||
|
display_name: "Bad".into(),
|
||||||
|
role: OrgRole::Member,
|
||||||
|
ed25519_pubkey: String::new(),
|
||||||
|
collections: vec![],
|
||||||
|
added_at: 0,
|
||||||
|
added_by: MemberId::new(),
|
||||||
|
});
|
||||||
|
assert!(members.validate().is_err());
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "WASM bindings for relicario password manager"
|
description = "WASM bindings for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
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.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Dev-C ARCHITECTURE.md slice — Plan C Phase 6 (`get_vault_status` + sidebar status indicator)
|
||||||
|
|
||||||
|
Ready-to-fold additions for `extension/ARCHITECTURE.md`, scoped to Dev-C's Phase 6 work only.
|
||||||
|
Phase 3 (`create_vault`/`attach_vault`, setup-SW migration) and Phase 4 (the `vault.ts` →
|
||||||
|
`vault-shell`/`vault-sidebar`/`vault-list`/`vault-drawer`/`vault-form-wrapper` split) doc updates
|
||||||
|
are Dev-A's / Dev-B's slices — not included here.
|
||||||
|
|
||||||
|
Merged to origin/main as `397cc78` (Merge Plan C Phase 6). Local source ref: `675452a`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SW message-protocol row — `get_vault_status` (read-only, popup-only)
|
||||||
|
|
||||||
|
**Where:** the `router/popup-only.ts` bullet in the service-worker module map (around line 270),
|
||||||
|
and/or wherever the read-only popup messages are enumerated.
|
||||||
|
|
||||||
|
**Add:**
|
||||||
|
|
||||||
|
> - `get_vault_status` (popup-only, read-only) — returns the cached sync summary
|
||||||
|
> `{ ahead, behind, lastSyncAt, pendingItems }` with **no network call**. `ahead`/`behind`/
|
||||||
|
> `lastSyncAt` are read straight off `state.gitHost` (populated by the `sync` handler, which
|
||||||
|
> records `lastSyncAt = Math.floor(Date.now()/1000)` — unix **seconds** — after a successful
|
||||||
|
> manifest fetch). `pendingItems` is a live count of active (non-trashed) manifest entries via
|
||||||
|
> `vault.listItems(manifest).length`. `ahead`/`behind` are structurally always `0` in the
|
||||||
|
> extension (it writes straight to the host via the Contents REST API; there is no local commit
|
||||||
|
> graph) and exist for parity with `relicario status`. Handler: `vault.handleGetVaultStatus(state)`
|
||||||
|
> — synchronous; its `Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks the
|
||||||
|
> `PopupState` import cycle and structurally forbids it from making a network call.
|
||||||
|
|
||||||
|
## 2. `git-host.ts` cache fields
|
||||||
|
|
||||||
|
**Where:** the `git-host.ts` bullet in the SW module map (around line 299, listing the interface methods).
|
||||||
|
|
||||||
|
**Amend** the interface description to note the cached sync metadata:
|
||||||
|
|
||||||
|
> The `GitHost` interface also carries cached sync metadata —
|
||||||
|
> `lastSyncAt: number | null` (unix seconds), `ahead: number`, `behind: number` — initialized to
|
||||||
|
> `null`/`0`/`0` in both `GiteaHost` and `GitHubHost`. The cache rides the gitHost lifecycle: it is
|
||||||
|
> created on unlock and cleared whenever `state.gitHost` is nulled — on session-timer expiry
|
||||||
|
> (`index.ts`) **and** on the explicit `lock` message handler (`popup-only.ts`), which now nulls
|
||||||
|
> `state.gitHost` symmetrically so a lock→unlock cycle can't surface a stale `lastSyncAt`.
|
||||||
|
|
||||||
|
## 3. Sidebar status-indicator UI flow
|
||||||
|
|
||||||
|
**Where:** the `src/vault/` module map (around line 184). Add a `vault-status.ts` entry and a note on
|
||||||
|
the `vault-sidebar.ts` footer wiring. (If Dev-B's Phase 4 slice has already added the `vault-sidebar.ts`
|
||||||
|
entry, fold the status note into it rather than duplicating.)
|
||||||
|
|
||||||
|
**Add:**
|
||||||
|
|
||||||
|
> - `vault-status.ts` — sidebar-footer sync indicator renderer. `renderStatusIndicator(el, status)`
|
||||||
|
> is pure DOM: it renders, by priority, `N pending` / `N ahead` / `N behind`, falling back to
|
||||||
|
> `in sync`, plus a `last sync <relativeTime>` / `never synced` line. Reuses `shared/glyphs.ts`
|
||||||
|
> (`GLYPH_PENDING`/`AHEAD`/`BEHIND`/`SYNCED`) and `shared/relative-time.ts`. `VaultStatus` is an
|
||||||
|
> alias of `GetVaultStatusResponse['data']`, so the renderer's input shape is single-sourced from
|
||||||
|
> the message contract and can't drift from the SW handler.
|
||||||
|
> - **Status-indicator flow** (in the `vault-sidebar.ts` entry): the footer holds a
|
||||||
|
> `#vault-status-slot` plus a manual `↻` refresh button (`GLYPH_REFRESH`). `wireSidebar` calls
|
||||||
|
> `refreshStatus()` once on mount and again on the button's click — sending `get_vault_status` via
|
||||||
|
> `ctx.sendMessage` and rendering the result into the slot. There is **no timer polling**: the
|
||||||
|
> indicator only refreshes on mount + explicit button press, matching the spec's
|
||||||
|
> no-network-without-user-intent discipline (sync is user-initiated).
|
||||||
|
|
||||||
|
## 4. Living-docs note
|
||||||
|
|
||||||
|
This closes the last `relicario status` CLI/extension parity gap (called out in the extension
|
||||||
|
restructure spec, `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`). `STATUS.md`
|
||||||
|
should move the extension-restructure line to shipped as part of the Task 7.1 pass.
|
||||||
178
docs/superpowers/coordination/v0.7-dev-c-prompt.md
Normal file
178
docs/superpowers/coordination/v0.7-dev-c-prompt.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Dev C Kickoff Prompt — v0.7.0 Plan C (Phase 6)
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a **senior developer** owning Plan C for the v0.7.0 "finish the extension restructure" release.
|
||||||
|
|
||||||
|
Your plan is **Phase 6 — `get_vault_status` SW handler + sidebar status indicator** (Tasks 6.1–6.3) of the extension restructure. You add the `get_vault_status` service-worker handler (returning cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` plus a live `pendingItems` count — no network call), build the `vault-status.ts` renderer for the sidebar-footer indicator, and wire it into the sidebar (refresh on mount + a manual ↻ button, **no timer polling**). This closes the last `relicario status` CLI/extension parity gap. Effort: S-M.
|
||||||
|
|
||||||
|
**⚠️ Your phase has cross-stream dependencies — read the coordination rules carefully.** Phase 6 depends on Phase 3 (Dev-A) for the `get_vault_status` message type and on Phase 4 (Dev-B) for the `vault-sidebar.ts` module you wire into.
|
||||||
|
|
||||||
|
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||||
|
|
||||||
|
## Setup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git fetch
|
||||||
|
git checkout main
|
||||||
|
git pull
|
||||||
|
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status -b phase-c-6-vault-status
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||||
|
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||||
|
```
|
||||||
|
|
||||||
|
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`** before any other instruction — otherwise the subagent may commit to main.
|
||||||
|
|
||||||
|
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"dev-c"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common pitfalls (avoid):**
|
||||||
|
|
||||||
|
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
|
||||||
|
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 6 only**)
|
||||||
|
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 6, Tasks 6.1–6.3**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
|
||||||
|
|
||||||
|
## Execution mode
|
||||||
|
|
||||||
|
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||||
|
|
||||||
|
**Every subagent prompt MUST start with**:
|
||||||
|
```
|
||||||
|
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
|
||||||
|
```
|
||||||
|
…before any other instruction. This is non-negotiable per project memory.
|
||||||
|
|
||||||
|
## Your scope and boundaries
|
||||||
|
|
||||||
|
**In scope:** Phase 6 Tasks 6.1–6.3 — `handleGetVaultStatus` in `service-worker/vault.ts` + cached `ahead`/`behind`/`lastSyncAt` fields on the git-host state + populating them in the `sync` handler + dispatch wiring in `popup-only.ts`; the `vault-status.ts` renderer + any new glyphs in `shared/glyphs.ts`; wiring the indicator into `vault-sidebar.ts`'s footer (mount + manual refresh). Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
|
||||||
|
|
||||||
|
**Out of scope:** Phase 3 (Dev-A owns `setup.ts`, ALL of `messages.ts`, and the `create_vault`/`attach_vault` handlers) and Phase 4 (Dev-B owns the `vault.ts` split, including *creating* `vault-sidebar.ts`). You only *modify* `vault-sidebar.ts` to add the wiring in Task 6.3. If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||||
|
|
||||||
|
**Hard rules — sequencing (this is the crux of your phase):**
|
||||||
|
|
||||||
|
- **Do NOT touch `shared/messages.ts`.** Dev-A (Phase 3, Task 3.1) defines the `get_vault_status` request type + `GetVaultStatusResponse` interface. You *import* `GetVaultStatusResponse` from `../shared/messages`; you never declare it. **Before you can compile Task 6.1, Dev-A's Task 3.1 must have landed on main** (or be available to merge). Confirm with the PM at kickoff. If it hasn't landed, ask the PM whether to wait or to proceed against a temporary local type and reconcile at merge — prefer waiting if Dev-A is close.
|
||||||
|
- **Stage your tasks 6.1 → 6.2 → 6.3.** Tasks 6.1 (SW handler) and 6.2 (renderer) are independent of Phase 4 and you can build them as soon as the `get_vault_status` type exists. **Task 6.3 wires into `vault-sidebar.ts`, which Dev-B (Phase 4) creates — you MUST wait for Dev-B's Phase 4 PR to merge before doing Task 6.3.** Ask the PM to confirm Phase 4 is merged, then pull main into your branch and do the wiring. Dev-B has been told to leave an empty `#vault-status-slot` footer element for you.
|
||||||
|
- Your `get_vault_status` handler is additive in `service-worker/vault.ts` alongside Dev-A's `create_vault`/`attach_vault` handlers. Expect a possible small merge conflict on the import block / dispatch switch in `service-worker/vault.ts` + `popup-only.ts`; the PM will sequence your SW handler merge after Dev-A's Phase 3.
|
||||||
|
- **No network in `get_vault_status`** — return cached state only. The spec is explicit: sync is user-initiated. **No timer polling** in the wiring — refresh on mount + manual ↻ button only.
|
||||||
|
- Do not merge your branch to main. The PM owns merges.
|
||||||
|
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
|
||||||
|
|
||||||
|
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task, **and especially when you are blocked waiting on Dev-A's or Dev-B's merge** (so the PM knows your idle is a dependency wait, not a stall). The `Notes` field narrates WHAT happened and WHY. Three sentences max. Print every STATUS UPDATE locally before/after sending it.
|
||||||
|
|
||||||
|
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-C
|
||||||
|
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
|
||||||
|
Branch: phase-c-6-vault-status
|
||||||
|
Task: <number / short name>
|
||||||
|
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||||
|
Last commit: <short sha + first line of message>
|
||||||
|
Tests: <green | red (which failed) | N/A>
|
||||||
|
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||||
|
```
|
||||||
|
|
||||||
|
**When you need PM input mid-task** (e.g. "is Phase 3's `get_vault_status` type merged yet?" / "is Phase 4 merged so I can do 6.3?"): post via `post_message(kind="question")` with format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## QUESTION TO PM — DEV-C
|
||||||
|
Time: <iso8601>
|
||||||
|
Context: <what task, what decision point>
|
||||||
|
Options: <A: ... / B: ... / C: ...>
|
||||||
|
Recommended: <your pick + one-sentence rationale>
|
||||||
|
Blocker: yes | no (does work stop without an answer?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**You'll receive**: `## DIRECTIVE TO DEV-C` blocks from the PM via relay. Acknowledge and act.
|
||||||
|
|
||||||
|
## Ship-it autonomy + simplify discipline
|
||||||
|
|
||||||
|
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||||
|
|
||||||
|
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||||
|
|
||||||
|
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes.
|
||||||
|
- Do not create parallel implementations of an existing helper (reuse `shared/relative-time.ts` for the timestamp; reuse the existing glyph family in `shared/glyphs.ts`).
|
||||||
|
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
|
||||||
|
- Default to no comments unless the WHY is non-obvious.
|
||||||
|
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||||
|
|
||||||
|
## Authority within the plan
|
||||||
|
|
||||||
|
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
|
||||||
|
|
||||||
|
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging; a discovered bug not in your plan; anything destructive; **the dependency waits (Phase 3 type / Phase 4 sidebar)**; before opening the PR for review.
|
||||||
|
|
||||||
|
## Final steps before REVIEW-READY
|
||||||
|
|
||||||
|
Run the project's full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push and open the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin phase-c-6-vault-status
|
||||||
|
gh pr create --base main --head phase-c-6-vault-status --title "feat(ext): Plan C Phase 6 — get_vault_status + sidebar status indicator" --body "$(cat <<'EOF'
|
||||||
|
## Plan C Phase 6 — get_vault_status + sidebar status indicator
|
||||||
|
|
||||||
|
Part of v0.7.0 (finish the extension restructure). Implements Phase 6 (Tasks 6.1–6.3) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`. Closes the `relicario status` CLI/extension parity gap.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
- `service-worker/vault.ts`: `handleGetVaultStatus` — returns cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` + live `pendingItems` from the manifest. No network call.
|
||||||
|
- `service-worker/git-host.ts`: cached `lastSyncAt`/`ahead`/`behind` fields, populated by the `sync` handler.
|
||||||
|
- `service-worker/router/popup-only.ts`: `get_vault_status` dispatch case.
|
||||||
|
- `vault/vault-status.ts`: sidebar-footer indicator renderer (in sync / N ahead / N behind / N pending / never synced); reuses `shared/relative-time.ts` + glyph family.
|
||||||
|
- `vault/vault-sidebar.ts`: wired the indicator into the footer slot — refresh on mount + manual ↻ button, no timer polling.
|
||||||
|
- Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
|
||||||
|
|
||||||
|
### Coordination notes
|
||||||
|
- Consumes the `get_vault_status` message type defined by Dev-A's Phase 3 (`messages.ts`); does not redefine it.
|
||||||
|
- Task 6.3 wiring lands on top of Dev-B's Phase 4 `vault-sidebar.ts` (merged first).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
|
||||||
|
- Done-criteria greps from the plan's Task 7.1 pass (`get_vault_status` dispatched + rendered, no network in handler, no polling timer).
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-6-vault-status`). Then immediately ask the PM (via `## QUESTION TO PM`) whether Dev-A's Phase 3 `get_vault_status` type has landed yet — that gates Task 6.1. While you wait, you can prepare the Task 6.2 renderer (`vault-status.ts`) since it only needs the local `VaultStatus` shape, not `messages.ts`.
|
||||||
68
docs/superpowers/coordination/v0.7-launch.sh
Executable file
68
docs/superpowers/coordination/v0.7-launch.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Auto-generated by multi-agent-kickoff — v0.7.0 (finish the extension restructure)
|
||||||
|
# Streams: Dev-A = Phase 3, Dev-B = Phase 4, Dev-C = Phase 6
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO="/home/alee/Sources/relicario"
|
||||||
|
RELAY_DIR="$REPO/tools/relay"
|
||||||
|
COORD="$REPO/docs/superpowers/coordination"
|
||||||
|
RELEASE="v0.7"
|
||||||
|
SESSION="$RELEASE"
|
||||||
|
|
||||||
|
# ── 1. Relay ─────────────────────────────────────────────────────────────
|
||||||
|
if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then
|
||||||
|
echo "[relay] already running on :7331"
|
||||||
|
else
|
||||||
|
echo "[relay] starting..."
|
||||||
|
cd "$RELAY_DIR"
|
||||||
|
nohup npx tsx server.ts > /tmp/relay-v0.7.log 2>&1 &
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
sleep 1
|
||||||
|
if curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1; then
|
||||||
|
echo "[relay] ready on :7331"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 10 ]; then
|
||||||
|
echo "[relay] ERROR: failed to start — check /tmp/relay-v0.7.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. tmux session ──────────────────────────────────────────────────────
|
||||||
|
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||||
|
echo "[tmux] session '$SESSION' already exists — attaching"
|
||||||
|
exec tmux attach-session -t "$SESSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[tmux] creating session '$SESSION'..."
|
||||||
|
tmux new-session -d -s "$SESSION" -n "PM"
|
||||||
|
tmux send-keys -t "$SESSION:PM" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-A"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-A" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-B"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-B" "claude" Enter
|
||||||
|
|
||||||
|
tmux new-window -t "$SESSION" -n "Dev-C"
|
||||||
|
tmux send-keys -t "$SESSION:Dev-C" "claude" Enter
|
||||||
|
|
||||||
|
tmux select-window -t "$SESSION:PM"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ v0.7.0 — finish the extension restructure — prompt cheatsheet ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════════╣"
|
||||||
|
echo "║ PM window → paste $COORD/v0.7-pm-prompt.md ║"
|
||||||
|
echo "║ Dev-A window → paste $COORD/v0.7-dev-a-prompt.md ║"
|
||||||
|
echo "║ Dev-B window → paste $COORD/v0.7-dev-b-prompt.md ║"
|
||||||
|
echo "║ Dev-C window → paste $COORD/v0.7-dev-c-prompt.md ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════════╣"
|
||||||
|
echo "║ A = Phase 3 (setup wizard SW migration) ║"
|
||||||
|
echo "║ B = Phase 4 (split vault.ts + vault_locked lift) ║"
|
||||||
|
echo "║ C = Phase 6 (get_vault_status + status indicator) — deps on A & B ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "[tmux] attaching — use Ctrl-b n / Ctrl-b p to switch windows"
|
||||||
|
exec tmux attach-session -t "$SESSION"
|
||||||
129
docs/superpowers/coordination/v0.7-pm-prompt.md
Normal file
129
docs/superpowers/coordination/v0.7-pm-prompt.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# PM Kickoff Prompt — v0.7.0 finish the extension restructure
|
||||||
|
|
||||||
|
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the **project manager** for the v0.7.0 "finish the extension restructure" release. 3 senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all 3+1 terminals and relays messages between them.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Working directory: `/home/alee/Sources/relicario`
|
||||||
|
- Branch: stay on `main`. Do not check out feature branches.
|
||||||
|
- Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
|
||||||
|
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||||
|
|
||||||
|
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/tools/relay
|
||||||
|
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||||
|
python3 call.py read_messages '{"for":"pm"}'
|
||||||
|
```
|
||||||
|
The shim connects over HTTP and has the same semantics as the MCP tools.
|
||||||
|
|
||||||
|
## Required reading (in order)
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — project rules
|
||||||
|
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — the bundle spec
|
||||||
|
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — the implementation plan. **Phases 1, 2, 5 already merged (2026-05-30).** This release finishes the remaining three:
|
||||||
|
- Plan A (Dev-A) → **Phase 3** (Tasks 3.1–3.7): setup wizard SW migration + step registry + `clearWizardState`
|
||||||
|
- Plan B (Dev-B) → **Phase 4** (Tasks 4.1–4.7): split `vault.ts` into 5 modules + lift the `vault_locked` channel into `shared/state.ts`
|
||||||
|
- Plan C (Dev-C) → **Phase 6** (Tasks 6.1–6.3): `get_vault_status` SW handler + sidebar status indicator
|
||||||
|
|
||||||
|
## Your authority
|
||||||
|
|
||||||
|
- Approve or deny scope changes from devs
|
||||||
|
- Review and merge PRs from each dev's feature branch
|
||||||
|
- Drive any release-prep work that isn't a feature plan (Task 7.1 final verification sweep, CHANGELOG, version bumps to v0.7.0, STATUS.md / ROADMAP.md updates) — this is your hands-on work
|
||||||
|
- Tag `v0.7.0` once everything is integrated **— but only after explicit user approval**
|
||||||
|
|
||||||
|
## Your boundaries
|
||||||
|
|
||||||
|
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` / STATUS / ROADMAP are fine.
|
||||||
|
- Don't deviate from the spec without user approval.
|
||||||
|
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||||
|
- Don't tag without user approval.
|
||||||
|
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`).
|
||||||
|
|
||||||
|
## ⚠️ Critical: cross-stream dependencies (the whole reason you exist this release)
|
||||||
|
|
||||||
|
Per the plan's "Notes on execution order": **Phase 4 blocks Phase 6, and Phase 3 owns a file Phase 6 needs.** Your central job is sequencing the merges and arbitrating the two shared edits:
|
||||||
|
|
||||||
|
1. **`extension/src/shared/messages.ts`** — Dev-A (Phase 3, Task 3.1) adds all three new request types: `create_vault`, `attach_vault`, **and `get_vault_status`**, plus their response interfaces, plus the three additions to `POPUP_ONLY_TYPES`. Dev-C (Phase 6) *consumes* `get_vault_status` but must NOT redefine it. **Directive at kickoff:** Dev-A owns every `messages.ts` change; Dev-C imports `GetVaultStatusResponse` from `messages.ts` and does not touch that file. If Dev-C starts before Dev-A's Task 3.1 lands, have Dev-C either (a) wait on the type, or (b) work against a local type alias and you reconcile at merge — prefer (a) if Dev-A is close.
|
||||||
|
|
||||||
|
2. **`extension/src/vault/vault-sidebar.ts`** — Dev-B (Phase 4, Task 4.2) *creates* this file. Dev-C (Phase 6, Task 6.3) *modifies* it to wire the status indicator into the sidebar footer. **Directive:** Dev-C should land Tasks 6.1 (SW handler) and 6.2 (renderer `vault-status.ts`) — both independent of Phase 4 — first, then HOLD on Task 6.3 until Dev-B's Phase 4 PR merges. Sequence the merges: **Phase 4 merges before Phase 6's wiring commit.**
|
||||||
|
|
||||||
|
3. **`extension/src/service-worker/vault.ts`** — Dev-A (Phase 3: `create_vault` / `attach_vault` handlers) and Dev-C (Phase 6: `get_vault_status` handler) both append handlers here, and both add a dispatch case to `service-worker/router/popup-only.ts`. These are additive and shouldn't conflict, but you may get a small merge conflict on the import block / switch statement. Merge Dev-A (Phase 3) before Dev-C's SW handler if possible to minimize churn. A trivial conflict here is expected — resolve it at merge or have the second dev rebase.
|
||||||
|
|
||||||
|
**Recommended merge order:** Phase 3 (Dev-A) → Phase 4 (Dev-B) → Phase 6 (Dev-C). Confirm this with the devs at kickoff so Dev-C knows to stage 6.1/6.2 early and 6.3 last.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of 3+1 terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action. If the relay MCP tools are not registered in your session, fall back to the Python shim (see **Relay server** section above) or ask the user to relay manually.
|
||||||
|
|
||||||
|
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is the PM terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly so the user sees the reasoning, not just the verdict. When you dispatch a subagent (e.g. for plan review or coherence pass), say so. One or two sentences per beat is plenty — the goal is for the user to read this terminal top-to-bottom and understand the release as a story.
|
||||||
|
|
||||||
|
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks, either from the relay inbox or relayed by the user if the relay is down.
|
||||||
|
|
||||||
|
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## DIRECTIVE TO DEV-<letter>
|
||||||
|
Time: <iso8601>
|
||||||
|
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||||
|
Notes: <one paragraph max>
|
||||||
|
Next: <one concrete instruction or "continue plan">
|
||||||
|
```
|
||||||
|
|
||||||
|
When asked "status?" by the user at any time, give a current rollup:
|
||||||
|
|
||||||
|
```
|
||||||
|
## RELEASE STATUS — v0.7.0
|
||||||
|
Devs: <per-dev one-line state>
|
||||||
|
PM: <what you're working on>
|
||||||
|
Blockers: <list, or "none">
|
||||||
|
Next milestone: <e.g., "Dev A REVIEW-READY", "tag v0.7.0">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reviewing PRs
|
||||||
|
|
||||||
|
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||||
|
1. `gh pr view <url>` to read description and CI status
|
||||||
|
2. `gh pr diff <url>` to read changes
|
||||||
|
3. Check the diff against the spec and plan acceptance criteria (the plan's "Final Verification" Task 7.1 lists the exact done-criteria greps — use them)
|
||||||
|
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash — project rule: git history is the audit log)
|
||||||
|
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
|
||||||
|
|
||||||
|
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving.
|
||||||
|
|
||||||
|
## Pre-tag checklist
|
||||||
|
|
||||||
|
Before tagging `v0.7.0`:
|
||||||
|
|
||||||
|
- [ ] Every dev branch merged to main (Phases 3, 4, 6)
|
||||||
|
- [ ] Task 7.1 done-criteria sweep passes (all greps in the plan's Final Verification section)
|
||||||
|
- [ ] `cd extension && npx tsc --noEmit` clean
|
||||||
|
- [ ] `cd extension && npx vitest run` green (baseline was 389/389 + new Phase 3/4/6 tests)
|
||||||
|
- [ ] `cd extension && npm run build:all` clean (only the pre-existing 4MB WASM warning)
|
||||||
|
- [ ] `cargo test` still green (these phases don't touch Rust, but confirm no accidental breakage)
|
||||||
|
- [ ] STATUS.md + ROADMAP.md moved extension restructure to "Shipped"; CHANGELOG.md v0.7.0 entry written; version bumped to v0.7.0
|
||||||
|
- [ ] User-driven smoke test of the merged result
|
||||||
|
- [ ] Explicit user approval to tag
|
||||||
|
|
||||||
|
After all PRs merge, run the project's cleanup (CLAUDE.md rule #6): `Workflow({name:"release", args:{action:"cleanup"}})` to remove this lift's worktrees and branches. **Note:** there are also stale `phase-c-1/2/5` worktrees from the previous lift (under `.worktrees/`) that were never cleaned up — flag this to the user; they may want them removed too (destructive op → ask first).
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||||
|
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the spec, the plan, and the cross-stream dependency map above.
|
||||||
|
3. Send opening directives to all three devs via `post_message` — at minimum: (a) confirm Dev-A owns ALL of `messages.ts`, (b) confirm the merge order Phase 3 → Phase 4 → Phase 6, (c) tell Dev-C to stage Tasks 6.1/6.2 first and HOLD 6.3 until Phase 4 merges.
|
||||||
|
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing them to proceed.
|
||||||
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
6178
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
6178
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
|||||||
|
# Relicario Enterprise Org Vault — Design Spec
|
||||||
|
|
||||||
|
**Scope:** Multi-user organizational vault for security-conscious self-hosting shops. Covers the git-native org model, the per-member key-wrapping scheme, collection-scoped item storage, role-based access control, org item CRUD, the signature-verifying pre-receive hook, the audit trail, and extension parity. Does not cover SSO/SAML, live SIEM streaming, or the HTTP management plane (deferred to a later server-tier spec).
|
||||||
|
|
||||||
|
**Next:** `docs/superpowers/specs/2026-05-02-relay-server-design.md` (relay server — future phase 2 management plane)
|
||||||
|
|
||||||
|
> **Revision note (2026-06-19):** This spec was revised after an adversarial multi-agent review of the first draft + its implementation plan. The review confirmed the cryptographic wrap/unwrap scheme is correct but found that the original access-control design was unenforceable (flat item paths the hook could not authorize), the hook never actually verified signatures, the audit actor was read from spoofable commit trailers, and there was no item CRUD. This revision corrects all of those at the design level. See `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md` for the implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Audience
|
||||||
|
|
||||||
|
Security-focused organizations that self-host their entire stack: infosec shops, security consultancies, law firms handling privileged client data, small financial firms. Key requirements:
|
||||||
|
|
||||||
|
- Full air-gap capability — no mandatory internet connectivity
|
||||||
|
- Cryptographically provenance-linked, **tamper-evident** audit trail
|
||||||
|
- Personal vaults remain isolated from org vault (separate cryptographic domains)
|
||||||
|
- Least-privilege blast-radius limiting via collections, **server-enforced** (not advisory)
|
||||||
|
- Member offboarding with clean key revocation that protects past secrets
|
||||||
|
- Deployable without an IdP, SSO provider, or cloud dependency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
An org vault is a second git repository alongside each member's personal vault. Personal and org vaults are cryptographically isolated — the personal vault's two-factor KDF (passphrase + image → Argon2id → master key) is completely untouched by org operations.
|
||||||
|
|
||||||
|
```
|
||||||
|
Personal: ~/.config/relicario/personal/ → personal git repo (passphrase + image → Argon2id → master key)
|
||||||
|
Org: ~/.config/relicario/acme-org/ → org git repo (org master key, wrapped per-member)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Two cryptographic domains, one CLI and one extension.**
|
||||||
|
|
||||||
|
The org vault uses a random 256-bit **org master key** to encrypt all org items and the org manifest. Each authorized member receives a copy of the org master key wrapped (ECIES: X25519 + XChaCha20-Poly1305) to their existing ed25519 device public key — converted to X25519 for the Diffie-Hellman step. To open the org vault, a member uses their device private key to unwrap their copy of the org master key, then decrypts items exactly as today.
|
||||||
|
|
||||||
|
**Two enforcement boundaries, working together:**
|
||||||
|
|
||||||
|
1. **Cryptographic** — only holders of a wrapped key can decrypt the org master key, and only the org master key can decrypt items. Revocation + key rotation re-encrypts everything under a fresh key.
|
||||||
|
2. **Git pre-receive hook** — every commit is signature-verified against `members.json`, and writes are authorized by role (for management files) or by **collection path segment** (for item files). This is what makes least-privilege real rather than advisory, and what makes the audit trail tamper-evident.
|
||||||
|
|
||||||
|
**Key security properties:**
|
||||||
|
|
||||||
|
- Member departure = delete their `keys/<member-id>.enc`, then `rotate-key` re-wraps the org key for remaining members **and re-encrypts every item blob**. A removed member who kept the old key and a clone can decrypt nothing written or rotated after their removal.
|
||||||
|
- Every write to the org repo is a **signed** git commit; the hook rejects unsigned commits and commits from non-members. The git log is the audit log, and its actor attribution comes from the **verified signing key**, not from spoofable commit-message text.
|
||||||
|
- Fully air-gapped: the org repo is just git, push/pull over SSH.
|
||||||
|
- A compromised org master key does not expose personal vault items.
|
||||||
|
|
||||||
|
**Phase 2 (not in this spec):** live SIEM streaming, SSO/SAML, LDAP/IdP member sync, HTTP management plane via the `relicario-server` relay skeleton, server-mediated read audit, and "hide value" (autofill without revealing plaintext).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
The org repo has a defined on-disk schema. The pre-receive hook rejects pushes that violate it.
|
||||||
|
|
||||||
|
```
|
||||||
|
acme-org/
|
||||||
|
├── org.json # org identity: name, org_id, created_at, schema_version
|
||||||
|
├── members.json # user directory (unencrypted — roles are not secrets)
|
||||||
|
├── collections.json # collection definitions
|
||||||
|
├── keys/
|
||||||
|
│ └── <member-id>.enc # org master key wrapped to each member's X25519 public key
|
||||||
|
├── manifest.enc # encrypted org manifest (item index + collection membership)
|
||||||
|
└── items/
|
||||||
|
└── <collection-slug>/
|
||||||
|
└── <item-id>.enc # encrypted item, stored UNDER its collection directory
|
||||||
|
```
|
||||||
|
|
||||||
|
**Collection-scoped item storage is load-bearing.** Items live under `items/<collection-slug>/<item-id>.enc`, not in a flat `items/` directory. The leading path segment is the collection slug, in cleartext, so the pre-receive hook can authorize a write by comparing the path's collection against the signing member's grants — *without* decrypting anything. (The original flat layout made this impossible: the item→collection mapping existed only inside the encrypted manifest the server cannot read.)
|
||||||
|
|
||||||
|
### `org.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"org_id": "<16-char hex>",
|
||||||
|
"display_name": "Acme Security",
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `members.json`
|
||||||
|
|
||||||
|
Public and unencrypted — readable without the org master key. Roles are not secrets; the key material is in `keys/`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"member_id": "<16-char hex>",
|
||||||
|
"display_name": "Alice",
|
||||||
|
"role": "owner",
|
||||||
|
"ed25519_pubkey": "ssh-ed25519 AAAA... ",
|
||||||
|
"collections": ["prod-infra", "shared-tools"],
|
||||||
|
"added_at": 1748000000,
|
||||||
|
"added_by": "<member-id>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`role` is one of `owner`, `admin`, `member`. `collections` is the list of collection slugs this member is granted. `member_id` is a 16-char lowercase hex string generated from 64 bits of `OsRng` entropy — the same convention as `ItemId`/`FieldId` in `relicario-core/src/ids.rs`. `ed25519_pubkey` is the member's device public key in OpenSSH format; the hook canonicalizes it to a SHA-256 fingerprint (via `relicario_core::fingerprint`) for matching, so whitespace/comment differences do not lock a member out.
|
||||||
|
|
||||||
|
### `collections.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"collections": [
|
||||||
|
{
|
||||||
|
"slug": "prod-infra",
|
||||||
|
"display_name": "Production Infrastructure",
|
||||||
|
"created_by": "<member-id>",
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Slugs are validated: non-empty, no `/`, no `.` (so they are safe single path segments).
|
||||||
|
|
||||||
|
### `keys/<member-id>.enc`
|
||||||
|
|
||||||
|
The org master key (32 bytes) encrypted with ECIES to the member's device key. Wrapped-blob layout: `ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag`. The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)`; all secret intermediates (shared secret, derived wrap key) are held in `Zeroizing`. The ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point) is the standard one; its correctness was verified against ed25519-dalek's own reference test vector.
|
||||||
|
|
||||||
|
### `manifest.enc`
|
||||||
|
|
||||||
|
Encrypted with the org master key. Same shape as the personal vault manifest but each entry carries a `collection` slug. The manifest is the authoritative item index; item blobs carry no metadata.
|
||||||
|
|
||||||
|
### `items/<collection-slug>/<item-id>.enc`
|
||||||
|
|
||||||
|
Identical `.enc` format to personal vault items (XChaCha20-Poly1305, random 24-byte nonce, org master key used directly — no Argon2id). Item IDs follow the 16-char hex convention. The blob does not name its collection; the directory path does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|---|---|
|
||||||
|
| **Owner** | All operations. Add/remove admins and owners. Create/delete collections. Rotate org key. Transfer ownership. Delete org. |
|
||||||
|
| **Admin** | Add/remove **members** (not owners/admins). Create/delete collections. Grant/revoke collection access. Read all collections. |
|
||||||
|
| **Member** | Read/write items in granted collections only. Cannot see or write items in other collections. |
|
||||||
|
|
||||||
|
Role gating is enforced both client-side (the CLI refuses) and server-side (the hook rejects). An admin cannot mint an owner or admin — only an owner can.
|
||||||
|
|
||||||
|
### Collection Access
|
||||||
|
|
||||||
|
Grants are stored in the member's `collections` array in `members.json`. No separate ACL file. An admin edits the member record and commits; the hook validates the committing member's role.
|
||||||
|
|
||||||
|
### Enforcement Layers
|
||||||
|
|
||||||
|
1. **Manifest filtering (read)** — the CLI and extension filter the decrypted manifest to entries whose `collection` is in the authenticated member's grant list. Members never see items for collections they are not granted.
|
||||||
|
|
||||||
|
2. **Pre-receive hook (write)** — for `items/<slug>/<id>.enc`, the hook requires `<slug>` to be in the signing member's grants. For `members.json` / `collections.json` / `org.json`, it requires owner/admin role. Every commit must additionally carry a **valid signature** from a current member. This makes both confidentiality *and integrity* of collections server-enforced.
|
||||||
|
|
||||||
|
### Known Limitations (honest)
|
||||||
|
|
||||||
|
- **Shared org master key — reads are not cryptographically scoped per collection.** Every member holds the *same* org master key (wrapped to their device key). The hook scopes *writes* by collection path and the client filters the *manifest* on read, but the cryptography itself does not partition reads: a member who obtains the raw ciphertext of an item in a collection they were not granted can still decrypt it, because the one org key opens everything. Collection grants are therefore an access-control boundary (enforced by the hook on write and by manifest filtering + optional git-host directory read-ACLs on fetch), not a cryptographic one. For *cryptographic* separation, put the sensitive material in a **separate org vault**. Per-collection subkeys are an explicit non-goal for this phase.
|
||||||
|
- **No read audit.** Git commits record writes, not reads. A member decrypting an item without writing leaves no git trace. Read audit needs a server that mediates fetch — phase 2.
|
||||||
|
- **No "hide value."** Autofill-without-revealing requires per-item subkeys or a mediating relay — phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Org Items (CRUD)
|
||||||
|
|
||||||
|
The org vault stores secrets via `relicario org` item commands that mirror the personal-vault item model (`Item`, `ItemCore`, the typed builders) but operate on the org repo and enforce collection grants.
|
||||||
|
|
||||||
|
```
|
||||||
|
relicario org add --collection <slug> <type> [type-specific flags]
|
||||||
|
relicario org get --collection <slug> <query> [--show] [--copy]
|
||||||
|
relicario org list [--collection <slug>] [--type <t>]
|
||||||
|
relicario org edit --collection <slug> <query>
|
||||||
|
relicario org rm | restore | purge --collection <slug> <query>
|
||||||
|
```
|
||||||
|
|
||||||
|
Every item operation:
|
||||||
|
|
||||||
|
1. Requires the caller's `current_member()` to have `<slug>` in their grants, and `<slug>` to exist in `collections.json`.
|
||||||
|
2. Reads/writes `items/<slug>/<id>.enc` with the org master key.
|
||||||
|
3. Upserts/removes the `OrgManifestEntry` (with `collection = <slug>`) and re-encrypts `manifest.enc`.
|
||||||
|
4. Commits with the structured trailer block, emitting the matching `item-*` action.
|
||||||
|
|
||||||
|
`get`/`list` apply manifest filtering so a member only sees their granted collections; secret fields are masked unless `--show`. Trash uses `trashed_at` like the personal vault.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Operations
|
||||||
|
|
||||||
|
All org management uses `relicario org <subcommand>`. Command bodies live in `commands/org.rs` as `run_<verb>`.
|
||||||
|
|
||||||
|
```
|
||||||
|
relicario org init --name "Acme Security" # create org repo, generate org key, add caller as owner, configure signing
|
||||||
|
relicario org add-member --key <openssh-pubkey> --name Alice --role member
|
||||||
|
relicario org remove-member <member-id> # delete key blob; prompts to run rotate-key
|
||||||
|
relicario org set-role <member-id> admin|member
|
||||||
|
relicario org create-collection <slug> --name "..."
|
||||||
|
relicario org grant <member-id> <slug>
|
||||||
|
relicario org revoke <member-id> <slug>
|
||||||
|
relicario org rotate-key # new org key: re-wrap for members AND re-encrypt all items + manifest
|
||||||
|
relicario org transfer-ownership <member-id> # owner → another member (owner only; caller demoted to admin unless --keep-owner)
|
||||||
|
relicario org delete-org # owner only; explicit confirmation; LOCAL tombstone only (see caveat below)
|
||||||
|
relicario org status # members, roles, collections — no decryption
|
||||||
|
relicario org audit [--since ..] [--member ..] [--collection ..] [--action ..] [--format json]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`delete-org` caveat (phase 1):** the pre-receive hook rejects deletion of the protected JSON files (`members.json` / `collections.json` / `org.json`) as part of schema-monotonicity enforcement. Therefore phase-1 `delete-org` is a **local tombstone only** — it removes the org files in the working tree and records a delete commit locally, but that commit **cannot be pushed to a hook-protected remote**. Pushing org teardown to a protected remote (a hook-side "owner may delete" exception) is a tracked phase-2 follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, owner-signed).
|
||||||
|
|
||||||
|
### Onboarding Flow
|
||||||
|
|
||||||
|
1. Alice runs `relicario device add`, exports her ed25519 public key (`signing.pub`).
|
||||||
|
2. Alice sends her public key to an admin out-of-band (Signal, email, printed QR — Relicario does not mediate key exchange).
|
||||||
|
3. Admin runs `org add-member --key <pubkey> --name Alice`. (An admin may add only `member` role; promoting to admin/owner requires an owner.)
|
||||||
|
4. Alice pulls the org repo. She can now open the org vault.
|
||||||
|
|
||||||
|
### Offboarding Flow
|
||||||
|
|
||||||
|
1. Admin runs `org remove-member <id>` (deletes the key blob, updates `members.json`).
|
||||||
|
2. Admin runs `org rotate-key` — generates a new org key, re-wraps it for remaining members, and **re-encrypts every item blob and the manifest** under the new key.
|
||||||
|
3. The former member, even with the old key and a clone, can decrypt nothing post-rotation.
|
||||||
|
|
||||||
|
### Signing
|
||||||
|
|
||||||
|
`org init` calls `configure_git_signing(org_root, device_name)` so the org repo signs commits with the device's ed25519 key. All org writes are signed; the hook rejects anything else.
|
||||||
|
|
||||||
|
### Extension — Org Context
|
||||||
|
|
||||||
|
The vault tab gains a top-level org switcher (Personal + each configured org). Switching loads the selected org's manifest through the service worker. The SW holds the unwrapped org master key in a `Zeroizing` session handle — identical to the personal master key. The org master key is **never** written to `localStorage`, `IndexedDB`, or any persistent browser storage. If the git remote is unreachable, the org context is read-only with an "org offline — writes disabled" indicator. Phase-1 extension scope is: org switching, browsing/reading org items (grant-filtered), and the parity acceptance tests; full in-extension org item editing may be a tracked follow-up if it balloons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Trail
|
||||||
|
|
||||||
|
### Git Log as Tamper-Evident Audit Record
|
||||||
|
|
||||||
|
Every write is a signed git commit carrying structured trailers:
|
||||||
|
|
||||||
|
```
|
||||||
|
add item to prod-infra collection
|
||||||
|
|
||||||
|
Relicario-Actor: alice <a1b2c3d4e5f6a1b2>
|
||||||
|
Relicario-Action: item-create
|
||||||
|
Relicario-Collection: prod-infra
|
||||||
|
Relicario-Item: 9f8e7d6c5b4a3f2e
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trailers are advisory, not authoritative.** A malicious committer can write any trailer text. The trustworthy actor identity is the **verified signing key**: `relicario org audit` resolves each commit's signature fingerprint to a `members.json` entry and reports that as the actor. Where the trailer's claimed actor disagrees with the verified signer, the commit is flagged `TAMPERED`. Timestamps use the committer date (`%cI`).
|
||||||
|
|
||||||
|
### Action Vocabulary
|
||||||
|
|
||||||
|
| `Relicario-Action` | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge` | org item add / edit / trash / restore / purge |
|
||||||
|
| `member-add` / `member-remove` / `member-role-change` | member management |
|
||||||
|
| `collection-create` / `collection-grant` / `collection-revoke` | collection management |
|
||||||
|
| `key-rotate` | org key rotation |
|
||||||
|
| `org-init` / `ownership-transfer` / `org-delete` | org lifecycle |
|
||||||
|
|
||||||
|
### `relicario org audit`
|
||||||
|
|
||||||
|
Parses `git log` (record separator `%x1e`, field separator `%x1f` to survive multi-line trailer values), resolves signer→member, applies `--since/--member/--collection/--action` filters, and emits a table or, with `--format json`, a JSON array ready for `… | <siem-ingest>` via cron. Each event includes the verified actor, action, collection, item, commit, committer timestamp, and a `tampered` flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Receive Hook (`relicario-server verify-org-commit`)
|
||||||
|
|
||||||
|
`relicario-server` gains an org mode. For each pushed commit it:
|
||||||
|
|
||||||
|
1. **Verifies the signature** by building a temporary `allowed_signers` from `members.json` ed25519 keys, injecting `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, running `git verify-commit --raw`, and parsing the `SHA256:` fingerprint from stderr — the same mechanism the existing `verify-commit` uses. A commit with no good signature, or whose signer is not a current member, is rejected. (Bare `git %GF` is **not** used — it returns empty without an allowed-signers file.)
|
||||||
|
2. **Authorizes the change** by inspecting `git diff-tree` paths:
|
||||||
|
- `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner.
|
||||||
|
- `items/<slug>/<id>.enc` → `<slug>` must be in the signing member's grants.
|
||||||
|
3. **Validates schema** — `schema_version` must not decrease for any of the three JSON files (compared against `{commit}^:<file>`), and `members.json`/`collections.json` must pass `validate()`.
|
||||||
|
4. **Handles genesis and merges** — the root commit (no parent) is the org-init genesis: it is allowed if signed by the sole owner it introduces. Merge commits are rejected (org history is linear) to avoid first-parent-only diff blind spots.
|
||||||
|
|
||||||
|
`relicario-server generate-org-hook` emits the wrapper script that runs `verify-org-commit` per pushed commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Key Rotation Race
|
||||||
|
|
||||||
|
`rotate-key` does `git pull --rebase` first. If the pull surfaces a non-fast-forward / conflict (a concurrent rotation), it aborts with `"Concurrent key rotation detected — pull and re-run org rotate-key."` A missing remote (local-only org) is distinguished and does not abort.
|
||||||
|
|
||||||
|
### Org Repo Schema Invalid
|
||||||
|
|
||||||
|
If `members.json`/`collections.json` fail validation on pull, the CLI refuses to open the org vault with a clear error. No silent degradation.
|
||||||
|
|
||||||
|
### Member Device Key Lost
|
||||||
|
|
||||||
|
If a member loses their device key before a backup device was added, an owner re-wraps the org key to a replacement device key the member generates. No master key escrow is needed — owners hold the org key and can always re-grant.
|
||||||
|
|
||||||
|
### Extension Offline
|
||||||
|
|
||||||
|
If the git remote is unreachable, the extension serves read-only from the last-pulled state and blocks writes with an indicator. Identical to personal vault offline behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (`relicario-core`)
|
||||||
|
|
||||||
|
- Org key wrap/unwrap round-trip (ed25519→X25519 + XChaCha20-Poly1305), including a pinned RFC 8032 known-answer vector so a future crate-version regression in the birational map is caught.
|
||||||
|
- Manifest filtering by collection grant list.
|
||||||
|
- `members.json` / `collections.json` schema validation (valid + invalid).
|
||||||
|
- Secret intermediates are `Zeroizing` (compile-level).
|
||||||
|
|
||||||
|
### Integration Tests (`relicario-cli`)
|
||||||
|
|
||||||
|
- Full lifecycle against a local bare git repo: `org init → add-member → create-collection → grant → org add (item write) → audit` — verifying the item lands at `items/<slug>/<id>.enc` and the audit attributes the verified signer.
|
||||||
|
- `remove-member → rotate-key` → former member cannot decrypt a re-encrypted item; remaining member can.
|
||||||
|
- Grant enforcement: a member without a collection grant is refused `org add/get` for it.
|
||||||
|
- `org audit --format json` is valid JSON matching the action vocabulary; a forged-trailer commit is flagged `TAMPERED`.
|
||||||
|
- Concurrent `rotate-key` race aborts with the spec error string.
|
||||||
|
|
||||||
|
### Hook Tests (`relicario-server`)
|
||||||
|
|
||||||
|
- Unsigned commit rejected; commit signed by a non-member rejected.
|
||||||
|
- Item write to an ungranted collection path rejected; to a granted one accepted.
|
||||||
|
- Protected-file write by a member (non-admin) rejected.
|
||||||
|
- `schema_version` decrease rejected. Genesis commit accepted; merge commit rejected.
|
||||||
|
|
||||||
|
### Extension Tests (vitest)
|
||||||
|
|
||||||
|
- SW org context switching replaces the personal manifest cleanly (no cross-contamination).
|
||||||
|
- Org master key lives only in the Zeroizing session — never in `localStorage`/`IndexedDB`.
|
||||||
|
- Offline read-only mode triggers on a git network error.
|
||||||
|
|
||||||
|
Org crypto bypasses Argon2id (key wrapping is X25519-based), so the fast-Argon2id test-params convention is irrelevant to org tests; standard params apply only where shared fixtures touch the personal path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Living-Docs Impact
|
||||||
|
|
||||||
|
This feature introduces new on-disk formats, a new crypto path, and a new dependency, so the following docs must be updated as the work lands (per CLAUDE.md living-docs discipline):
|
||||||
|
|
||||||
|
- `docs/FORMATS.md` — the four org JSON files, the `keys/<id>.enc` wrapped-blob layout, and `items/<slug>/<id>.enc`.
|
||||||
|
- `docs/CRYPTO.md` — the ECIES org-key wrap/unwrap path and key-rotation re-encryption.
|
||||||
|
- `DESIGN.md` — org-master-key row in the secrets map; the `x25519-dalek` dependency; relicario-server org mode.
|
||||||
|
- `docs/SECURITY.md` — org device-key auth, the signature-verifying hook, and the honest limitations above.
|
||||||
|
- `crates/relicario-core/ARCHITECTURE.md` and `crates/relicario-cli/ARCHITECTURE.md` — the new `org` modules.
|
||||||
|
- `STATUS.md` / `ROADMAP.md` — the org-vault track and any tracked follow-ups (e.g. full extension org editing, SSO/LDAP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
This spec covers phase 1 (git-native org, CLI + extension parity). Phase 2 adds:
|
||||||
|
|
||||||
|
- HTTP management plane via `relicario-server` (relay skeleton → org API)
|
||||||
|
- Live audit event streaming to SIEM (webhooks, not cron-poll)
|
||||||
|
- SSO/SAML assertion validation + LDAP/IdP member sync
|
||||||
|
- Server-mediated read audit
|
||||||
|
- "Hide value" autofill (per-item subkeys or server-mediated relay)
|
||||||
|
- Per-collection cryptographic isolation (subkeys — explicit non-goal for phase 1)
|
||||||
|
- Pushable `delete-org` org teardown (a hook-side "owner may delete protected files" exception); phase-1 `delete-org` is a local tombstone only
|
||||||
@@ -37,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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `content` | `src/content/detector.ts` | host page (top frame only by router check) | no |
|
||||||
|
|
||||||
### What each bundle owns
|
### What each bundle owns
|
||||||
@@ -183,17 +183,51 @@ before any new render.
|
|||||||
|
|
||||||
### `src/vault/`
|
### `src/vault/`
|
||||||
|
|
||||||
- `vault.ts` — fullscreen tab entry. Hash-based router (`#detail/<id>`,
|
- `vault.ts` (194 lines) — fullscreen tab entry, now a thin
|
||||||
`#add/<type>`, `#trash`, `#devices`, `#settings`, `#settings-vault`,
|
routing + state shell after the Phase 4 split. Registers itself as
|
||||||
`#history`, `#history/<id>`, `#backup`, `#import`). Legacy
|
the StateHost so all `popup/components/*` renderers run unchanged,
|
||||||
`#field-history/<id>` URLs are normalized to `#history/<id>` on
|
maintains its own `selectedItem` cache so hash navigation between
|
||||||
`parseHash` (`vault.ts:139-173`); the internal view value stays
|
already-loaded items doesn't refetch, and delegates DOM scaffolding,
|
||||||
`'field-history'` so the per-item pane renders unchanged. Sidebar
|
navigation, list/drawer/form rendering, and route dispatch to the
|
||||||
bottom-nav: `+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history
|
sibling modules below. The hash-route set is
|
||||||
· ⏻ lock`. Registers itself as the StateHost so all
|
`#detail/<id>`, `#add/<type>`, `#trash`, `#devices`, `#settings`,
|
||||||
`popup/components/*` renderers run unchanged. Maintains its own
|
`#settings-vault`, `#history`, `#history/<id>`, `#backup`, `#import`.
|
||||||
`selectedItem` cache so hash navigation between already-loaded items
|
- `vault-context.ts` — the `VaultController` contract plus the shared
|
||||||
doesn't refetch.
|
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.
|
- `vault.html` / `vault.css` — sidebar + pane layout.
|
||||||
|
|
||||||
### `src/vault/components/`
|
### `src/vault/components/`
|
||||||
@@ -211,12 +245,19 @@ exports `render…(app)` and a `teardown()`, same convention as
|
|||||||
|
|
||||||
### `src/setup/`
|
### `src/setup/`
|
||||||
|
|
||||||
- `setup.ts` (1137 lines) — the wizard state machine. Six steps
|
- `setup.ts` (58 lines) — a thin UI-only shell after the Phase 3
|
||||||
(0..5): mode picker (new vault / attach this device), host type
|
split: the render loop + progress track + boot + re-exports. No longer
|
||||||
(Gitea/GitHub), host config + connection test + repo probe, the
|
imports `relicario-wasm`; the wizard now drives vault creation/attach
|
||||||
forking step 3 (create-vault vs attach-this-device), device name,
|
through the SW. Binds `clearWizardState` to
|
||||||
finish. Loads WASM directly. State-coupled `updateStrengthUi` stays
|
`window.addEventListener('beforeunload', clearWizardState)`
|
||||||
here because it walks the live wizard state.
|
(`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
|
- `setup-helpers.ts` (84 lines, extracted in commit `f79a67b`) — pure
|
||||||
helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms
|
helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms
|
||||||
debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the
|
debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the
|
||||||
@@ -273,7 +314,23 @@ exports `render…(app)` and a `teardown()`, same convention as
|
|||||||
`session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate,
|
`session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate,
|
||||||
re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here
|
re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here
|
||||||
with its own captured-tab verification (see Key flows). New in
|
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
|
- `router/content-callable.ts` — handler match arms for every
|
||||||
`CONTENT_CALLABLE_TYPES` message. Origin always derived from
|
`CONTENT_CALLABLE_TYPES` message. Origin always derived from
|
||||||
`sender.tab.url`, never from message fields. `capture_save_login`
|
`sender.tab.url`, never from message fields. `capture_save_login`
|
||||||
@@ -287,7 +344,13 @@ exports `render…(app)` and a `teardown()`, same convention as
|
|||||||
no www-stripping, no public-suffix), trash helpers
|
no www-stripping, no public-suffix), trash helpers
|
||||||
(`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and
|
(`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and
|
||||||
attachment helpers (`addAttachmentToItem`, `removeAttachmentsFromItem`,
|
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
|
- `session.ts` — single module-scope `SessionHandle | null`. α assumes
|
||||||
one vault per install. Multi-vault would replace this with a `Map`
|
one vault per install. Multi-vault would replace this with a `Map`
|
||||||
keyed by vault id.
|
keyed by vault id.
|
||||||
@@ -301,6 +364,15 @@ exports `render…(app)` and a `teardown()`, same convention as
|
|||||||
`putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory.
|
`putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory.
|
||||||
`BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which
|
`BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which
|
||||||
attachment writes switch from the Contents API to the Git Data API.
|
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
|
- `gitea.ts` / `github.ts` — the two GitHost implementations. Both use
|
||||||
the host's Contents API for files under threshold, and Git Data API
|
the host's Contents API for files under threshold, and Git Data API
|
||||||
(blobs + tree + commit) for large attachment uploads. Auth differs
|
(blobs + tree + commit) for large attachment uploads. Auth differs
|
||||||
@@ -322,7 +394,9 @@ exports `render…(app)` and a `teardown()`, same convention as
|
|||||||
- `state.ts` — `StateHost` interface + module-scope singleton. Both
|
- `state.ts` — `StateHost` interface + module-scope singleton. Both
|
||||||
`popup.ts` and `vault.ts` register themselves on boot. All
|
`popup.ts` and `vault.ts` register themselves on boot. All
|
||||||
`popup/components/*` import from here, never from popup.ts directly,
|
`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:
|
- `types.ts` — TypeScript mirrors of the Rust core's serde shapes:
|
||||||
`Item`, `ItemCore` (internally-tagged on `type`), `Field` and
|
`Item`, `ItemCore` (internally-tagged on `type`), `Field` and
|
||||||
`FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`,
|
`FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Relicario",
|
"name": "Relicario",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"description": "Two-factor encrypted password manager",
|
"description": "Two-factor encrypted password manager",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Relicario",
|
"name": "Relicario",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"description": "Two-factor encrypted password manager",
|
"description": "Two-factor encrypted password manager",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "relicario-extension",
|
"name": "relicario-extension",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
|
|||||||
@@ -93,9 +93,21 @@ setupFillListener();
|
|||||||
scan();
|
scan();
|
||||||
|
|
||||||
// Watch for DOM changes (SPA navigation, dynamically loaded forms).
|
// 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
|
||||||
scan();
|
// 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, {
|
observer.observe(document.body, {
|
||||||
childList: true,
|
childList: true,
|
||||||
|
|||||||
@@ -95,6 +95,32 @@ describe('devices view', () => {
|
|||||||
expect(app.querySelector<HTMLButtonElement>('#register-confirm-btn')).not.toBeNull();
|
expect(app.querySelector<HTMLButtonElement>('#register-confirm-btn')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 () => {
|
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' });
|
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||||
// Initial render: list_devices + list_revoked.
|
// Initial render: list_devices + list_revoked.
|
||||||
|
|||||||
@@ -31,35 +31,64 @@ export function teardown(): void {
|
|||||||
// No cleanup needed
|
// 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> {
|
export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||||
// Get current device name from local storage
|
// Get current device name from local storage
|
||||||
const stored = await chrome.storage.local.get(['device_name']);
|
const stored = await chrome.storage.local.get(['device_name']);
|
||||||
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
||||||
|
|
||||||
// Fetch active device list and revoked list in parallel
|
// Fetch active device list and revoked list in parallel. allSettled so a
|
||||||
const [devicesResp, revokedResp] = await Promise.all([
|
// rejected secondary feed doesn't kill the whole render.
|
||||||
|
const [devicesSettled, revokedSettled] = await Promise.allSettled([
|
||||||
sendMessage({ type: 'list_devices' }),
|
sendMessage({ type: 'list_devices' }),
|
||||||
sendMessage({ type: 'list_revoked' }),
|
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>`;
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
// devicesSettled.value.ok is true here (guarded above), so .data is present.
|
||||||
const revokedDevices: RevokedEntry[] = revokedResp.ok
|
const devicesData = (devicesSettled.value as { ok: true; data: unknown }).data;
|
||||||
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
|
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);
|
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
||||||
|
|
||||||
// Precompute fingerprints for all active devices
|
// 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 fingerprints = new Map<string, string>();
|
||||||
await Promise.all(devices.map(async (d) => {
|
const fpResults = await Promise.allSettled(
|
||||||
const fp = await sshFingerprint(d.public_key);
|
devices.map((d) => sshFingerprint(d.public_key).then((fp) => [d.name, fp] as const)),
|
||||||
fingerprints.set(d.name, fp ?? '(unknown)');
|
);
|
||||||
}));
|
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
|
const activeDevicesHtml = devices.length === 0
|
||||||
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||||||
@@ -82,7 +111,9 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
|
const revokedSectionHtml = !revokedOk
|
||||||
|
? revokedLoadErrorHtml()
|
||||||
|
: revokedDevices.length === 0 ? '' : `
|
||||||
<details class="revoked-section">
|
<details class="revoked-section">
|
||||||
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
|
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
|
||||||
<div class="revoked-section__body">
|
<div class="revoked-section__body">
|
||||||
@@ -117,7 +148,7 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
|||||||
` : ''}
|
` : ''}
|
||||||
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
|
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
|
||||||
${activeDevicesHtml}
|
${activeDevicesHtml}
|
||||||
${revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : ''}
|
${!revokedOk ? `<div class="section-header">REVOKED · ?</div>` : (revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : '')}
|
||||||
${revokedSectionHtml}
|
${revokedSectionHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
import type { SessionTimeoutConfig } from '../../shared/messages';
|
import type { SessionTimeoutConfig } from '../../shared/messages';
|
||||||
import { relativeTime } from '../../shared/relative-time';
|
import { relativeTime } from '../../shared/relative-time';
|
||||||
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||||
|
import { teardownSettingsCommon } from './settings';
|
||||||
import { GLYPH_NEXT } from '../../shared/glyphs';
|
import { GLYPH_NEXT } from '../../shared/glyphs';
|
||||||
|
|
||||||
let pendingSettings: VaultSettings | null = null;
|
let pendingSettings: VaultSettings | null = null;
|
||||||
@@ -17,11 +18,7 @@ let pendingSession: SessionTimeoutConfig | null = null;
|
|||||||
let baseSession: SessionTimeoutConfig | null = null;
|
let baseSession: SessionTimeoutConfig | null = null;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
closeGeneratorPanel();
|
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
|
||||||
if (activeKeyHandler) {
|
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
|
||||||
activeKeyHandler = null;
|
|
||||||
}
|
|
||||||
pendingSettings = null;
|
pendingSettings = null;
|
||||||
pendingSession = null;
|
pendingSession = null;
|
||||||
baseSession = null;
|
baseSession = null;
|
||||||
|
|||||||
@@ -53,13 +53,29 @@ export async function renderSettings(container: HTMLElement): Promise<void> {
|
|||||||
await renderSection(activeSection);
|
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();
|
closeGeneratorPanel();
|
||||||
teardownSecuritySection();
|
if (keyHandler) {
|
||||||
if (activeKeyHandler) {
|
document.removeEventListener('keydown', keyHandler);
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
|
||||||
activeKeyHandler = null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardownSettings(): void {
|
||||||
|
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
|
||||||
|
teardownSecuritySection();
|
||||||
pendingVaultSettings = null;
|
pendingVaultSettings = null;
|
||||||
sessionHandle = null;
|
sessionHandle = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
|
|||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
||||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
|
import type { View, PopupState } from '../shared/popup-state';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentState: PopupState = {
|
let currentState: PopupState = {
|
||||||
view: 'locked',
|
view: 'locked',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import * as timer from '../session-timer';
|
import * as timer from '../session-timer';
|
||||||
|
import { READ_ONLY_CONTENT_CALLABLE } from '../session-timer';
|
||||||
|
|
||||||
describe('session-timer', () => {
|
describe('session-timer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -97,3 +98,29 @@ describe('session-timer', () => {
|
|||||||
expect(cb).not.toHaveBeenCalled();
|
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;
|
/// Delete a blob from the repo. Currently identical to deleteFile;
|
||||||
/// kept distinct for symmetry with putBlob.
|
/// kept distinct for symmetry with putBlob.
|
||||||
deleteBlob(path: string, message: string): Promise<void>;
|
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
|
/// 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 keysUrl: string;
|
||||||
private branch: string = 'main';
|
private branch: string = 'main';
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
lastSyncAt: number | null = null;
|
||||||
|
ahead = 0;
|
||||||
|
behind = 0;
|
||||||
|
|
||||||
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
||||||
// Remove trailing slash from hostUrl
|
// Remove trailing slash from hostUrl
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export class GitHubHost implements GitHost {
|
|||||||
private commitsUrl: string;
|
private commitsUrl: string;
|
||||||
private branch: string = 'main';
|
private branch: string = 'main';
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
lastSyncAt: number | null = null;
|
||||||
|
ahead = 0;
|
||||||
|
behind = 0;
|
||||||
|
|
||||||
constructor(repoPath: string, apiToken: string) {
|
constructor(repoPath: string, apiToken: string) {
|
||||||
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
/// forwards every message into router/index.route().
|
/// forwards every message into router/index.route().
|
||||||
|
|
||||||
import type { Request, Response, SessionTimeoutConfig } from '../shared/messages';
|
import type { Request, Response, SessionTimeoutConfig } from '../shared/messages';
|
||||||
import { CONTENT_CALLABLE_TYPES } from '../shared/messages';
|
|
||||||
import type { RouterState } from './router/index';
|
import type { RouterState } from './router/index';
|
||||||
import { route } from './router/index';
|
import { route } from './router/index';
|
||||||
import * as vault from './vault';
|
import * as vault from './vault';
|
||||||
import { clearCurrent } from './session';
|
import { clearCurrent } from './session';
|
||||||
import * as sessionTimer from './session-timer';
|
import * as sessionTimer from './session-timer';
|
||||||
|
import { READ_ONLY_CONTENT_CALLABLE } from './session-timer';
|
||||||
|
|
||||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||||
@@ -53,6 +53,9 @@ sessionTimer.onExpired(() => {
|
|||||||
console.log('[relicario sw] session expired — locking vault');
|
console.log('[relicario sw] session expired — locking vault');
|
||||||
clearCurrent();
|
clearCurrent();
|
||||||
state.manifest = null;
|
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.
|
// Best-effort broadcast — receiver may not exist yet.
|
||||||
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -73,7 +76,10 @@ chrome.commands.onCommand.addListener((command) => {
|
|||||||
chrome.runtime.onMessage.addListener(
|
chrome.runtime.onMessage.addListener(
|
||||||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||||||
(async () => {
|
(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();
|
sessionTimer.resetTimer();
|
||||||
}
|
}
|
||||||
if (!state.wasm) {
|
if (!state.wasm) {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type { ContentMessage, Response } from '../../shared/messages';
|
|||||||
import type { Item, Manifest } from '../../shared/types';
|
import type { Item, Manifest } from '../../shared/types';
|
||||||
import type { GitHost } from '../git-host';
|
import type { GitHost } from '../git-host';
|
||||||
import * as vault from '../vault';
|
import * as vault from '../vault';
|
||||||
|
import { itemToManifestEntry } from '../vault';
|
||||||
import * as session from '../session';
|
import * as session from '../session';
|
||||||
|
import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
|
||||||
|
|
||||||
export interface ContentState {
|
export interface ContentState {
|
||||||
manifest: Manifest | null;
|
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 {
|
function safeHostname(url: string): string | undefined {
|
||||||
try { return new URL(url).hostname; } catch { return 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).
|
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||||
|
|
||||||
import type { PopupMessage, Response } from '../../shared/messages';
|
import type { PopupMessage, Response } from '../../shared/messages';
|
||||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
import type { Item, ItemId, Manifest, VaultConfig, SetupState, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
|
||||||
import { base32Decode } from '../../shared/base32';
|
import { base32Decode } from '../../shared/base32';
|
||||||
import type { GitHost } from '../git-host';
|
import type { GitHost } from '../git-host';
|
||||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||||
import * as vault from '../vault';
|
import * as vault from '../vault';
|
||||||
|
import { itemToManifestEntry } from '../vault';
|
||||||
import * as session from '../session';
|
import * as session from '../session';
|
||||||
import * as devices from '../devices';
|
import * as devices from '../devices';
|
||||||
import * as sessionTimer from '../session-timer';
|
import * as sessionTimer from '../session-timer';
|
||||||
|
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
|
||||||
|
|
||||||
// --- Shared ambient state owned by the SW module ---
|
// --- Shared ambient state owned by the SW module ---
|
||||||
//
|
//
|
||||||
@@ -58,6 +59,9 @@ export async function handle(
|
|||||||
case 'lock':
|
case 'lock':
|
||||||
session.clearCurrent();
|
session.clearCurrent();
|
||||||
state.manifest = null;
|
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 };
|
return { ok: true };
|
||||||
|
|
||||||
case 'list_items': {
|
case 'list_items': {
|
||||||
@@ -129,6 +133,8 @@ export async function handle(
|
|||||||
const handle = session.getCurrent();
|
const handle = session.getCurrent();
|
||||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
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 };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +632,18 @@ export async function handle(
|
|||||||
return { ok: false, error: (e as Error).message };
|
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 };
|
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 {
|
function safeHostname(url: string): string | undefined {
|
||||||
try { return new URL(url).hostname; } catch { return undefined; }
|
try { return new URL(url).hostname; } catch { return undefined; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,3 +48,17 @@ export function stopTimer(): void {
|
|||||||
timerId = null;
|
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 { SessionHandle } from '../../wasm/relicario_wasm';
|
||||||
import type { GitHost } from './git-host';
|
import type { GitHost } from './git-host';
|
||||||
import { uint8ArrayToBase64 } from './git-host';
|
import { createGitHost, uint8ArrayToBase64 } from './git-host';
|
||||||
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let wasm: any = null;
|
let wasm: any = null;
|
||||||
@@ -17,6 +21,125 @@ function requireWasm(): any {
|
|||||||
return wasm;
|
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 {
|
export interface VaultMeta {
|
||||||
salt: Uint8Array;
|
salt: Uint8Array;
|
||||||
paramsJson: string;
|
paramsJson: string;
|
||||||
@@ -395,3 +518,62 @@ export async function removeAttachmentsFromItem(
|
|||||||
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
|
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
|
||||||
return removed;
|
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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { finishSetup } from '../setup';
|
import { finishSetup, STEPS } from '../setup';
|
||||||
|
import { state, clearWizardState } from '../setup-steps';
|
||||||
|
|
||||||
describe('finishSetup', () => {
|
describe('finishSetup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -35,3 +36,47 @@ describe('finishSetup', () => {
|
|||||||
expect(chrome.tabs.create).toHaveBeenCalled();
|
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
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,6 +19,11 @@ export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
|||||||
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
||||||
export const GLYPH_COPY = '⎘'; // copy to clipboard
|
export const GLYPH_COPY = '⎘'; // copy to clipboard
|
||||||
export const GLYPH_SYNC = '⇅'; // sync / upload
|
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_PREVIEW = '⊕'; // preview / expand
|
||||||
|
|
||||||
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
|
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ export type PopupMessage =
|
|||||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||||
| { type: 'preview_totp_from_secret'; secret_b32: string }
|
| { type: 'preview_totp_from_secret'; secret_b32: string }
|
||||||
| { type: 'generate_recovery_qr'; passphrase: 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 ---
|
// --- 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',
|
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||||
'preview_totp_from_secret',
|
'preview_totp_from_secret',
|
||||||
'generate_recovery_qr', 'unwrap_recovery_qr',
|
'generate_recovery_qr', 'unwrap_recovery_qr',
|
||||||
|
'create_vault', 'attach_vault', 'get_vault_status',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
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([
|
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||||
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
||||||
'capture_save_login',
|
'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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
/// Service-locator for cross-bundle state access.
|
// extension/src/shared/state.ts
|
||||||
///
|
//
|
||||||
/// Both popup.ts and vault.ts register themselves as the "host".
|
// Single channel for popup and vault-tab UI to read/write app state and
|
||||||
/// All popup components import from here instead of from popup.ts,
|
// dispatch messages to the service worker. Two registered hosts (popup,
|
||||||
/// so the same component code works in either bundle.
|
// vault tab) implement StateHost; each surface calls registerHost(this) at
|
||||||
|
// boot.
|
||||||
|
//
|
||||||
|
// The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts
|
||||||
|
// into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature;
|
||||||
|
// the body is a thin pass-through until Phase 4.
|
||||||
|
|
||||||
import type { Request, Response } from './messages';
|
import type { Request, Response } from './messages';
|
||||||
|
import type { PopupState, View } from './popup-state';
|
||||||
|
|
||||||
export interface StateHost {
|
export interface StateHost {
|
||||||
getState(): any;
|
getState(): PopupState;
|
||||||
setState(partial: any): void;
|
setState(partial: Partial<PopupState>): void;
|
||||||
navigate(view: string, extras?: any): void;
|
navigate(view: View, extras?: Partial<PopupState>): void;
|
||||||
sendMessage(request: Request): Promise<Response>;
|
sendMessage(request: Request): Promise<Response>;
|
||||||
escapeHtml(s: string): string;
|
escapeHtml(s: string): string;
|
||||||
popOutToTab(): void;
|
popOutToTab(): void;
|
||||||
@@ -19,26 +25,58 @@ export interface StateHost {
|
|||||||
|
|
||||||
let host: StateHost | null = null;
|
let host: StateHost | null = null;
|
||||||
|
|
||||||
export function registerHost(h: StateHost): void { host = h; }
|
export function registerHost(h: StateHost): void {
|
||||||
|
if (host) throw new Error('state host already registered');
|
||||||
|
host = h;
|
||||||
|
}
|
||||||
|
|
||||||
export function getState(): any {
|
/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */
|
||||||
|
export function __resetHostForTests(): void {
|
||||||
|
host = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getState(): PopupState {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
return host.getState();
|
return host.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setState(partial: any): void {
|
export function setState(partial: Partial<PopupState>): void {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
host.setState(partial);
|
host.setState(partial);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigate(view: string, extras?: any): void {
|
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
host.navigate(view, extras);
|
host.navigate(view, extras);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendMessage(request: Request): Promise<Response> {
|
// Requests that must NOT trigger the lock screen on a vault_locked response:
|
||||||
|
// they run during cold start / unlock, before a session exists, so a
|
||||||
|
// vault_locked here is expected rather than a lost session.
|
||||||
|
const VAULT_LOCKED_BYPASS: ReadonlySet<Request['type']> = new Set([
|
||||||
|
'unlock', 'is_unlocked',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a request to the service worker and intercepts the `vault_locked`
|
||||||
|
* response. MV3 evicts the service worker after ~30s idle, wiping the in-memory
|
||||||
|
* session/manifest; the next RPC comes back `vault_locked`. Any surface (popup
|
||||||
|
* or vault tab) that gets that on a non-bypassed request treats it as "session
|
||||||
|
* lost" and navigates to the lock screen so the user can re-enter their
|
||||||
|
* passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4
|
||||||
|
* so both surfaces share one channel.
|
||||||
|
*/
|
||||||
|
export async function sendMessage(request: Request): Promise<Response> {
|
||||||
if (!host) throw new Error('No state host registered');
|
if (!host) throw new Error('No state host registered');
|
||||||
return host.sendMessage(request);
|
const response = await host.sendMessage(request);
|
||||||
|
if (
|
||||||
|
!response.ok &&
|
||||||
|
response.error === 'vault_locked' &&
|
||||||
|
!VAULT_LOCKED_BYPASS.has(request.type)
|
||||||
|
) {
|
||||||
|
host.navigate('locked', { error: 'Session expired — please unlock again.' });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHtml(s: string): string {
|
export function escapeHtml(s: string): string {
|
||||||
@@ -52,7 +90,7 @@ export function popOutToTab(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isInTab(): boolean {
|
export function isInTab(): boolean {
|
||||||
if (!host) return false;
|
if (!host) throw new Error('No state host registered');
|
||||||
return host.isInTab();
|
return host.isInTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
28
extension/src/vault/__tests__/drawer-state.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ensureDrawerClosedForRoute } from '../vault-drawer';
|
||||||
|
|
||||||
|
describe('ensureDrawerClosedForRoute', () => {
|
||||||
|
it('closes the drawer when navigating to trash', () => {
|
||||||
|
const state = { drawerOpen: true };
|
||||||
|
ensureDrawerClosedForRoute(state, { view: 'trash' });
|
||||||
|
expect(state.drawerOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the drawer open when navigating to detail', () => {
|
||||||
|
const state = { drawerOpen: true };
|
||||||
|
ensureDrawerClosedForRoute(state, { view: 'detail' });
|
||||||
|
expect(state.drawerOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the drawer open in list view', () => {
|
||||||
|
const state = { drawerOpen: true };
|
||||||
|
ensureDrawerClosedForRoute(state, { view: 'list' });
|
||||||
|
expect(state.drawerOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the drawer is already closed', () => {
|
||||||
|
const state = { drawerOpen: false };
|
||||||
|
ensureDrawerClosedForRoute(state, { view: 'devices' });
|
||||||
|
expect(state.drawerOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
|||||||
|
|
||||||
describe('fullscreen form dirty subtitle', () => {
|
describe('fullscreen form dirty subtitle', () => {
|
||||||
const vaultSrc = fs.readFileSync(
|
const vaultSrc = fs.readFileSync(
|
||||||
path.resolve(__dirname, '../vault.ts'),
|
path.resolve(__dirname, '../vault-form-wrapper.ts'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
|||||||
|
|
||||||
describe('vault sidebar glyphs', () => {
|
describe('vault sidebar glyphs', () => {
|
||||||
const vaultSrc = fs.readFileSync(
|
const vaultSrc = fs.readFileSync(
|
||||||
path.resolve(__dirname, '../vault.ts'),
|
path.resolve(__dirname, '../vault-sidebar.ts'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
34
extension/src/vault/__tests__/status-indicator.test.ts
Normal file
34
extension/src/vault/__tests__/status-indicator.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { renderStatusIndicator } from '../vault-status';
|
||||||
|
|
||||||
|
describe('vault status indicator', () => {
|
||||||
|
it('renders "in sync" when ahead/behind/pending all zero', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 });
|
||||||
|
expect(el.textContent).toMatch(/in sync/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "N ahead" when ahead is non-zero', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, { ahead: 3, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 });
|
||||||
|
expect(el.textContent).toMatch(/3 ahead/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "N behind" when behind is non-zero', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, { ahead: 0, behind: 2, lastSyncAt: 1700000000, pendingItems: 0 });
|
||||||
|
expect(el.textContent).toMatch(/2 behind/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "N pending" when pendingItems is non-zero', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 5 });
|
||||||
|
expect(el.textContent).toMatch(/5 pending/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "never synced" when lastSyncAt is null', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 });
|
||||||
|
expect(el.textContent).toMatch(/never synced/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
extension/src/vault/__tests__/status-integration.test.ts
Normal file
65
extension/src/vault/__tests__/status-integration.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { handleGetVaultStatus } from '../../service-worker/vault';
|
||||||
|
import { renderStatusIndicator } from '../vault-status';
|
||||||
|
import type { Manifest, ManifestEntry } from '../../shared/types';
|
||||||
|
|
||||||
|
// Integration seam: the get_vault_status SW handler (Phase 6 Task 6.1) produces
|
||||||
|
// the exact data shape the sidebar renderer (Task 6.2) consumes. This pins the
|
||||||
|
// contract between the two so a future change to either side can't silently
|
||||||
|
// drift the keys apart. It does NOT touch vault-sidebar.ts — the wiring layer
|
||||||
|
// (Task 6.3) is Dev-B's boundary and lands separately.
|
||||||
|
|
||||||
|
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('vault status: handler → renderer integration', () => {
|
||||||
|
it('renders "in sync" from a freshly-synced, no-pending handler response', () => {
|
||||||
|
const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(0) });
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, resp.data);
|
||||||
|
expect(el.textContent).toMatch(/in sync/i);
|
||||||
|
expect(el.textContent).toMatch(/last sync/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the handler\'s active-item count as "N pending" in the DOM', () => {
|
||||||
|
const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(7, 3) });
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
expect(resp.data.pendingItems).toBe(7);
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, resp.data);
|
||||||
|
expect(el.textContent).toMatch(/7 pending/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces cached ahead/behind from the handler in the DOM', () => {
|
||||||
|
const resp = handleGetVaultStatus({ gitHost: cache(1700000000, 2, 1), manifest: manifestWith(0) });
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, resp.data);
|
||||||
|
expect(el.textContent).toMatch(/2 ahead/i);
|
||||||
|
expect(el.textContent).toMatch(/1 behind/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "never synced" when the handler reports a null lastSyncAt', () => {
|
||||||
|
const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: manifestWith(0) });
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
renderStatusIndicator(el, resp.data);
|
||||||
|
expect(el.textContent).toMatch(/never synced/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
extension/src/vault/__tests__/vault-sidebar-status.test.ts
Normal file
62
extension/src/vault/__tests__/vault-sidebar-status.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderSidebarShell, wireSidebar } from '../vault-sidebar';
|
||||||
|
import type { VaultController } from '../vault-context';
|
||||||
|
|
||||||
|
const STATUS = { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 4 };
|
||||||
|
|
||||||
|
function makeCtx() {
|
||||||
|
return {
|
||||||
|
state: { searchQuery: '' },
|
||||||
|
sendMessage: vi.fn(async (req: { type: string }) =>
|
||||||
|
req.type === 'get_vault_status'
|
||||||
|
? { ok: true, data: STATUS }
|
||||||
|
: { ok: true }),
|
||||||
|
render: vi.fn(),
|
||||||
|
renderPane: vi.fn(),
|
||||||
|
renderListPane: vi.fn(),
|
||||||
|
closeDrawer: vi.fn(),
|
||||||
|
openTypePanel: vi.fn(),
|
||||||
|
setHash: vi.fn(),
|
||||||
|
applyShellViewClass: vi.fn(),
|
||||||
|
} as unknown as VaultController;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusCalls(ctx: VaultController): number {
|
||||||
|
return (ctx.sendMessage as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
.filter((c) => (c[0] as { type: string }).type === 'get_vault_status').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('vault-sidebar status wiring', () => {
|
||||||
|
beforeEach(() => { document.body.innerHTML = renderSidebarShell(); });
|
||||||
|
afterEach(() => { document.body.innerHTML = ''; });
|
||||||
|
|
||||||
|
it('fetches + renders the indicator on mount', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
wireSidebar(ctx);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.getElementById('vault-status-slot')?.textContent).toMatch(/4 pending/i);
|
||||||
|
});
|
||||||
|
expect(statusCalls(ctx)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-fetches on the manual refresh button', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
wireSidebar(ctx);
|
||||||
|
await vi.waitFor(() => expect(statusCalls(ctx)).toBe(1));
|
||||||
|
document.getElementById('status-refresh-btn')?.dispatchEvent(new Event('click'));
|
||||||
|
await vi.waitFor(() => expect(statusCalls(ctx)).toBe(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT poll on a timer', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
wireSidebar(ctx);
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
// Only the single mount fetch — no interval re-fetches.
|
||||||
|
expect(statusCalls(ctx)).toBe(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
122
extension/src/vault/vault-context.ts
Normal file
122
extension/src/vault/vault-context.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Shared contract for the vault-tab modules. vault.ts owns the state
|
||||||
|
// singleton and assembles the VaultController; each vault-* module receives
|
||||||
|
// it as `ctx`. This module sits at the bottom of the dependency graph —
|
||||||
|
// it imports only from shared/, never from vault.ts or its sibling modules.
|
||||||
|
|
||||||
|
import type { Request, Response } from '../shared/messages';
|
||||||
|
import type {
|
||||||
|
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
||||||
|
} from '../shared/types';
|
||||||
|
import {
|
||||||
|
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
||||||
|
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
||||||
|
} from '../shared/glyphs';
|
||||||
|
|
||||||
|
export type VaultView =
|
||||||
|
| 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings'
|
||||||
|
| 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import';
|
||||||
|
|
||||||
|
export interface HashRoute {
|
||||||
|
view: VaultView;
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultState {
|
||||||
|
unlocked: boolean;
|
||||||
|
view: VaultView;
|
||||||
|
entries: Array<[ItemId, ManifestEntry]>;
|
||||||
|
selectedId: ItemId | null;
|
||||||
|
selectedItem: Item | null;
|
||||||
|
selectedIndex: number;
|
||||||
|
searchQuery: string;
|
||||||
|
activeGroup: string | null;
|
||||||
|
drawerOpen: boolean;
|
||||||
|
typePanelOpen: boolean;
|
||||||
|
vaultSettings: VaultSettings | null;
|
||||||
|
generatorDefaults: GeneratorRequest | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
newType: ItemType | null;
|
||||||
|
capturedTabId: number | null;
|
||||||
|
capturedUrl: string;
|
||||||
|
historyItemId: ItemId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller passed to every vault-* module. vault.ts builds one instance
|
||||||
|
// and wires each hook to the function that currently lives in vault.ts (later
|
||||||
|
// Phase-4 tasks repoint individual hooks at the extracted module functions).
|
||||||
|
export interface VaultController {
|
||||||
|
readonly state: VaultState;
|
||||||
|
sendMessage(request: Request): Promise<Response>;
|
||||||
|
render(): void;
|
||||||
|
renderPane(): void;
|
||||||
|
renderListPane(): void;
|
||||||
|
renderSidebarCategories(): void;
|
||||||
|
renderDrawer(item: Item): void;
|
||||||
|
applyShellViewClass(): void;
|
||||||
|
setHash(view: VaultView, param?: string): void;
|
||||||
|
openDrawer(): void;
|
||||||
|
closeDrawer(): void;
|
||||||
|
selectItemForDrawer(id: string): Promise<void>;
|
||||||
|
openTypePanel(): void;
|
||||||
|
closeTypePanel(): void;
|
||||||
|
wireSidebar(): void;
|
||||||
|
loadManifest(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure helpers (no state, no DOM dependencies beyond the args) ---
|
||||||
|
|
||||||
|
export function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeIcon(t: ItemType): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'login': return GLYPH_TYPE_LOGIN;
|
||||||
|
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||||
|
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||||
|
case 'card': return GLYPH_TYPE_CARD;
|
||||||
|
case 'key': return GLYPH_TYPE_KEY;
|
||||||
|
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||||
|
case 'totp': return GLYPH_TYPE_TOTP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeLabel(t: ItemType): string {
|
||||||
|
const labels: Record<ItemType, string> = {
|
||||||
|
login: 'Login',
|
||||||
|
secure_note: 'Secure Note',
|
||||||
|
identity: 'Identity',
|
||||||
|
card: 'Card',
|
||||||
|
key: 'SSH / API Key',
|
||||||
|
document: 'Document',
|
||||||
|
totp: 'TOTP',
|
||||||
|
};
|
||||||
|
return labels[t];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilteredEntries(
|
||||||
|
state: VaultState,
|
||||||
|
): Array<[ItemId, ManifestEntry]> {
|
||||||
|
let filtered = state.entries.filter(
|
||||||
|
([, e]) => e.trashed_at === undefined || e.trashed_at === null,
|
||||||
|
);
|
||||||
|
if (state.searchQuery) {
|
||||||
|
const q = state.searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(([, e]) => {
|
||||||
|
if (e.title.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.icon_hint?.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.group?.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
138
extension/src/vault/vault-drawer.ts
Normal file
138
extension/src/vault/vault-drawer.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Vault-tab drawer: the right-hand overlay that previews a selected item
|
||||||
|
// (open/close/render + item selection). Receives the VaultController (`ctx`)
|
||||||
|
// and reaches sibling concerns through it; pure helpers come from
|
||||||
|
// vault-context. Imports only from shared/ and vault-context.
|
||||||
|
|
||||||
|
import type { Item } from '../shared/types';
|
||||||
|
import {
|
||||||
|
type VaultController, type VaultState, type HashRoute, escapeHtml,
|
||||||
|
} from './vault-context';
|
||||||
|
|
||||||
|
export function openDrawer(): void {
|
||||||
|
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDrawer(ctx: VaultController): void {
|
||||||
|
ctx.state.drawerOpen = false;
|
||||||
|
ctx.state.selectedId = null;
|
||||||
|
ctx.state.selectedItem = null;
|
||||||
|
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
|
||||||
|
const core = item.core as unknown as Record<string, unknown>;
|
||||||
|
if (!core) return [];
|
||||||
|
const fields: Array<[string, string, boolean]> = [];
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case 'login':
|
||||||
|
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
|
||||||
|
if ('password' in core) fields.push(['password', '••••••••', false]);
|
||||||
|
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
|
||||||
|
break;
|
||||||
|
case 'card': {
|
||||||
|
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
|
||||||
|
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
|
||||||
|
if ('expiry' in core && core.expiry) {
|
||||||
|
const exp = core.expiry as { month: number; year: number };
|
||||||
|
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
|
||||||
|
}
|
||||||
|
if ('cvv' in core) fields.push(['cvv', '•••', false]);
|
||||||
|
if ('pin' in core) fields.push(['pin', '••••', false]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'identity':
|
||||||
|
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
|
||||||
|
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
|
||||||
|
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
|
||||||
|
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
|
||||||
|
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
|
||||||
|
break;
|
||||||
|
case 'key':
|
||||||
|
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
|
||||||
|
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
|
||||||
|
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
|
||||||
|
break;
|
||||||
|
case 'secure_note':
|
||||||
|
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
|
||||||
|
break;
|
||||||
|
case 'totp':
|
||||||
|
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
|
||||||
|
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
|
||||||
|
break;
|
||||||
|
case 'document':
|
||||||
|
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
|
||||||
|
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.notes) fields.push(['notes', item.notes, true]);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDrawer(ctx: VaultController, item: Item): void {
|
||||||
|
const drawer = document.getElementById('vault-drawer');
|
||||||
|
if (!drawer) return;
|
||||||
|
|
||||||
|
const coreFields = getDrawerCoreFields(item);
|
||||||
|
|
||||||
|
drawer.innerHTML = `
|
||||||
|
<div class="vault-drawer__header">
|
||||||
|
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
||||||
|
<div class="vault-drawer__actions">
|
||||||
|
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
||||||
|
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vault-drawer__body">
|
||||||
|
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
||||||
|
${item.type === 'login' && (item.core as { url?: string }).url
|
||||||
|
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
||||||
|
: ''}
|
||||||
|
<div class="vault-drawer__field-grid">
|
||||||
|
${coreFields.map(([label, value, full]) => `
|
||||||
|
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
||||||
|
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
||||||
|
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
|
||||||
|
closeDrawer(ctx);
|
||||||
|
ctx.renderListPane();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
|
||||||
|
if (ctx.state.selectedId) {
|
||||||
|
ctx.setHash('edit', ctx.state.selectedId);
|
||||||
|
ctx.renderPane();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectItemForDrawer(ctx: VaultController, id: string): Promise<void> {
|
||||||
|
const resp = await ctx.sendMessage({ type: 'get_item', id });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = resp.data as { item: Item };
|
||||||
|
ctx.state.selectedId = id;
|
||||||
|
ctx.state.selectedItem = data.item;
|
||||||
|
ctx.state.drawerOpen = true;
|
||||||
|
ctx.renderSidebarCategories();
|
||||||
|
ctx.renderListPane();
|
||||||
|
renderDrawer(ctx, data.item);
|
||||||
|
openDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawer is an overlay only meaningful on the list/detail surfaces; any
|
||||||
|
// other route must clear it so it doesn't leak across navigation (P2 fix).
|
||||||
|
const DRAWER_KEEPING_VIEWS: ReadonlySet<string> = new Set(['list', 'detail']);
|
||||||
|
|
||||||
|
export function ensureDrawerClosedForRoute(
|
||||||
|
state: Pick<VaultState, 'drawerOpen'>,
|
||||||
|
route: Pick<HashRoute, 'view'>,
|
||||||
|
): void {
|
||||||
|
if (!DRAWER_KEEPING_VIEWS.has(route.view)) state.drawerOpen = false;
|
||||||
|
}
|
||||||
72
extension/src/vault/vault-form-wrapper.ts
Normal file
72
extension/src/vault/vault-form-wrapper.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Fullscreen form wrapper for the vault tab: sticky save bar + scrollable
|
||||||
|
// content + header with a live dirty-state subtitle. Receives the
|
||||||
|
// VaultController (`ctx`) for the item-type read; imports only from shared/,
|
||||||
|
// the popup item-form component, and vault-context.
|
||||||
|
|
||||||
|
import { renderItemForm } from '../popup/components/item-form';
|
||||||
|
import { type VaultController } from './vault-context';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Platform-aware save hint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||||
|
const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fullscreen form wrapper — sticky save bar + scrollable content + header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function renderFormWrapped(ctx: VaultController, app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||||
|
const itemType = ctx.state.selectedItem?.type ?? ctx.state.newType ?? 'login';
|
||||||
|
const typeLabelText = itemType.replace('_', ' ');
|
||||||
|
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'form-pane';
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<div class="fullscreen-form-header">
|
||||||
|
<div>
|
||||||
|
<div class="title">${titleText}</div>
|
||||||
|
<div class="sub" id="form-dirty-sub">no changes</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">${SAVE_HINT}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-scroll" id="form-scroll"></div>
|
||||||
|
<div class="sticky-save-bar">
|
||||||
|
<button class="btn-secondary" id="form-cancel">cancel</button>
|
||||||
|
<button class="btn-primary" id="form-save">save</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// Remove pane padding so form-pane can fill height cleanly
|
||||||
|
app.style.padding = '0';
|
||||||
|
app.style.overflow = 'hidden';
|
||||||
|
app.replaceChildren(wrapper);
|
||||||
|
|
||||||
|
const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement;
|
||||||
|
renderItemForm(scrollEl, mode);
|
||||||
|
|
||||||
|
const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement;
|
||||||
|
let isDirty = false;
|
||||||
|
const markDirty = () => {
|
||||||
|
if (isDirty) return;
|
||||||
|
isDirty = true;
|
||||||
|
subEl.textContent = 'unsaved · esc to cancel';
|
||||||
|
};
|
||||||
|
const markClean = () => {
|
||||||
|
isDirty = false;
|
||||||
|
subEl.textContent = 'no changes';
|
||||||
|
};
|
||||||
|
scrollEl.addEventListener('input', markDirty, true);
|
||||||
|
scrollEl.addEventListener('change', markDirty, true);
|
||||||
|
|
||||||
|
wrapper.querySelector('#form-cancel')?.addEventListener('click', () => {
|
||||||
|
markClean();
|
||||||
|
(scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click();
|
||||||
|
});
|
||||||
|
wrapper.querySelector('#form-save')?.addEventListener('click', () => {
|
||||||
|
markClean();
|
||||||
|
(scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __test__ = { renderFormWrapped };
|
||||||
52
extension/src/vault/vault-list.ts
Normal file
52
extension/src/vault/vault-list.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Vault-tab list column: renders the middle list pane (row markup, empty
|
||||||
|
// state, and the row-click → drawer selection). Receives the VaultController
|
||||||
|
// (`ctx`) and reaches sibling concerns through it; pure helpers come from
|
||||||
|
// vault-context. Imports only from shared/ and vault-context.
|
||||||
|
|
||||||
|
import type { ItemId, ManifestEntry, ItemType } from '../shared/types';
|
||||||
|
import { relativeTime } from '../shared/relative-time';
|
||||||
|
import {
|
||||||
|
type VaultController, escapeHtml, typeIcon, getFilteredEntries,
|
||||||
|
} from './vault-context';
|
||||||
|
|
||||||
|
export function renderListPane(ctx: VaultController): void {
|
||||||
|
const pane = document.getElementById('vault-list-pane');
|
||||||
|
if (!pane) return;
|
||||||
|
|
||||||
|
const group = ctx.state.activeGroup as ItemType | null;
|
||||||
|
let items = getFilteredEntries(ctx.state);
|
||||||
|
if (group) items = items.filter(([, e]) => e.type === group);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="empty-state__icon" aria-hidden="true">${ctx.state.searchQuery ? '⊘' : '◈'}</span>
|
||||||
|
<div class="empty-state__title">${ctx.state.searchQuery ? `No results for "${escapeHtml(ctx.state.searchQuery)}"` : 'No items yet'}</div>
|
||||||
|
<div class="empty-state__hint">${ctx.state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pane.innerHTML = items.map(([id, e]: [ItemId, ManifestEntry]) => {
|
||||||
|
const sel = id === ctx.state.selectedId ? ' vault-list-row--selected' : '';
|
||||||
|
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
|
||||||
|
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
|
||||||
|
return `
|
||||||
|
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
|
||||||
|
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
|
||||||
|
<div class="vault-list-row__text">
|
||||||
|
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
|
||||||
|
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
|
||||||
|
row.addEventListener('click', async () => {
|
||||||
|
await ctx.selectItemForDrawer(row.dataset.id!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
205
extension/src/vault/vault-router.ts
Normal file
205
extension/src/vault/vault-router.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Vault-tab routing core: hash parsing/serialization, pane dispatch (delegating
|
||||||
|
// to the shared popup components), and data loading. Receives the
|
||||||
|
// VaultController (`ctx`) and reaches sibling concerns through it. Imports only
|
||||||
|
// from shared/, the popup components, vault-context, vault-drawer, and
|
||||||
|
// vault-form-wrapper — never from vault.ts or the shell/sidebar/list modules.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ItemId, ItemType, ManifestEntry, Item, VaultSettings,
|
||||||
|
} from '../shared/types';
|
||||||
|
import { renderItemDetail } from '../popup/components/item-detail';
|
||||||
|
import { renderItemForm } from '../popup/components/item-form';
|
||||||
|
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
|
||||||
|
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
|
||||||
|
import { renderSettings, teardownSettings } from '../popup/components/settings';
|
||||||
|
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||||
|
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||||
|
import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index';
|
||||||
|
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||||
|
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||||
|
import {
|
||||||
|
type VaultController, type VaultView, type HashRoute,
|
||||||
|
} from './vault-context';
|
||||||
|
import { ensureDrawerClosedForRoute } from './vault-drawer';
|
||||||
|
import { renderFormWrapped } from './vault-form-wrapper';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hash routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function parseHash(): HashRoute {
|
||||||
|
let raw = window.location.hash.replace(/^#\/?/, '');
|
||||||
|
if (!raw) return { view: 'list' };
|
||||||
|
|
||||||
|
// Normalize legacy bookmarks: #field-history/<id> → #history/<id>
|
||||||
|
if (raw.startsWith('field-history/')) {
|
||||||
|
raw = 'history/' + raw.slice('field-history/'.length);
|
||||||
|
window.location.hash = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = raw.split('/');
|
||||||
|
const view = parts[0] as VaultView;
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case 'detail':
|
||||||
|
case 'edit':
|
||||||
|
return { view, id: parts[1] };
|
||||||
|
case 'add':
|
||||||
|
return { view, type: parts[1] };
|
||||||
|
case 'history':
|
||||||
|
return parts[1]
|
||||||
|
? { view: 'field-history', id: parts[1] }
|
||||||
|
: { view: 'history' };
|
||||||
|
case 'trash':
|
||||||
|
case 'devices':
|
||||||
|
case 'settings':
|
||||||
|
case 'settings-vault':
|
||||||
|
case 'field-history':
|
||||||
|
case 'backup':
|
||||||
|
case 'import':
|
||||||
|
return { view };
|
||||||
|
default:
|
||||||
|
return { view: 'list' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHash(view: VaultView, param?: string): void {
|
||||||
|
const fragment = param ? `${view}/${param}` : view;
|
||||||
|
window.location.hash = fragment === 'list' ? '' : fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pane rendering — delegates to shared popup components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function teardownPaneComponents(): void {
|
||||||
|
teardownTrash();
|
||||||
|
teardownDevices();
|
||||||
|
teardownSettings();
|
||||||
|
teardownFieldHistory();
|
||||||
|
teardownHistoryIndex();
|
||||||
|
teardownBackup();
|
||||||
|
teardownImport();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPane(ctx: VaultController): void {
|
||||||
|
const pane = document.getElementById('vault-pane');
|
||||||
|
if (!pane) return;
|
||||||
|
|
||||||
|
teardownPaneComponents();
|
||||||
|
|
||||||
|
const route = parseHash();
|
||||||
|
ensureDrawerClosedForRoute(ctx.state, route);
|
||||||
|
// Keep state.view in sync with hash for components that read it
|
||||||
|
ctx.state.view = route.view;
|
||||||
|
ctx.applyShellViewClass();
|
||||||
|
|
||||||
|
pane.className = 'vault-pane';
|
||||||
|
|
||||||
|
switch (route.view) {
|
||||||
|
case 'detail':
|
||||||
|
if (ctx.state.selectedItem) {
|
||||||
|
renderItemDetail(pane);
|
||||||
|
} else {
|
||||||
|
pane.className = 'vault-pane vault-pane--empty';
|
||||||
|
pane.innerHTML = 'select an item';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'add':
|
||||||
|
// Prefer hash type for deep-links; otherwise keep the in-memory value
|
||||||
|
// set by the type-selection click handler (which calls setState →
|
||||||
|
// renderPane before the URL hash has been updated to include the type).
|
||||||
|
ctx.state.newType = (route.type as ItemType) ?? ctx.state.newType ?? null;
|
||||||
|
// Use the form wrapper (sticky bar + header) when a type is already chosen.
|
||||||
|
// Without a type the type-selection screen renders — no sticky bar needed.
|
||||||
|
if (ctx.state.newType) {
|
||||||
|
renderFormWrapped(ctx, pane, 'add');
|
||||||
|
} else {
|
||||||
|
renderItemForm(pane, 'add');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
renderFormWrapped(ctx, pane, 'edit');
|
||||||
|
break;
|
||||||
|
case 'trash':
|
||||||
|
renderTrash(pane);
|
||||||
|
break;
|
||||||
|
case 'devices':
|
||||||
|
renderDevices(pane);
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
void renderSettings(pane);
|
||||||
|
break;
|
||||||
|
case 'settings-vault':
|
||||||
|
renderVaultSettingsView(pane);
|
||||||
|
break;
|
||||||
|
case 'field-history':
|
||||||
|
renderFieldHistory(pane);
|
||||||
|
break;
|
||||||
|
case 'history':
|
||||||
|
renderItemHistoryIndex(pane);
|
||||||
|
break;
|
||||||
|
case 'backup':
|
||||||
|
renderBackupPanel(pane);
|
||||||
|
break;
|
||||||
|
case 'import':
|
||||||
|
renderImportPanel(pane);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
pane.className = 'vault-pane vault-pane--empty';
|
||||||
|
pane.innerHTML = 'select an item';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function loadManifest(ctx: VaultController): Promise<void> {
|
||||||
|
const listResp = await ctx.sendMessage({ type: 'list_items' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
|
ctx.state.entries = data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vsResp = await ctx.sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (vsResp.ok) {
|
||||||
|
const data = vsResp.data as { settings: VaultSettings };
|
||||||
|
ctx.state.vaultSettings = data.settings;
|
||||||
|
ctx.state.generatorDefaults = data.settings.generator_defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deep link from hash
|
||||||
|
const route = parseHash();
|
||||||
|
if (route.view === 'detail' && route.id) {
|
||||||
|
const itemResp = await ctx.sendMessage({ type: 'get_item', id: route.id });
|
||||||
|
if (itemResp.ok) {
|
||||||
|
const data = itemResp.data as { item: Item };
|
||||||
|
ctx.state.selectedId = route.id;
|
||||||
|
ctx.state.selectedItem = data.item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy selectItem — used by hash-change deep linking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function selectItem(ctx: VaultController, id: ItemId): Promise<void> {
|
||||||
|
ctx.state.loading = true;
|
||||||
|
const resp = await ctx.sendMessage({ type: 'get_item', id });
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { item: Item };
|
||||||
|
ctx.state.selectedId = id;
|
||||||
|
ctx.state.selectedItem = data.item;
|
||||||
|
ctx.state.loading = false;
|
||||||
|
setHash('detail', id);
|
||||||
|
ctx.renderSidebarCategories();
|
||||||
|
ctx.renderListPane();
|
||||||
|
renderPane(ctx);
|
||||||
|
} else {
|
||||||
|
ctx.state.loading = false;
|
||||||
|
ctx.state.error = (resp as { error: string }).error;
|
||||||
|
}
|
||||||
|
}
|
||||||
236
extension/src/vault/vault-shell.ts
Normal file
236
extension/src/vault/vault-shell.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// Vault-tab shell: render entry point, lock screen, 3-column shell
|
||||||
|
// scaffolding, the right-side type-picker panel, color-scheme apply, and the
|
||||||
|
// session_expired listener. Each function receives the VaultController (`ctx`)
|
||||||
|
// and reaches sibling concerns through it; pure helpers come from
|
||||||
|
// vault-context. vault.ts owns the state singleton and assembles the ctx.
|
||||||
|
|
||||||
|
import type { ItemType } from '../shared/types';
|
||||||
|
import { lookupErrorCopy } from '../shared/error-copy';
|
||||||
|
import { applyColorScheme } from '../shared/color-scheme';
|
||||||
|
import {
|
||||||
|
type VaultController, escapeHtml, typeIcon,
|
||||||
|
} from './vault-context';
|
||||||
|
import { renderSidebarShell } from './vault-sidebar';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Type picker (right side panel)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [
|
||||||
|
{ type: 'login', label: 'Login' },
|
||||||
|
{ type: 'secure_note', label: 'Secure Note' },
|
||||||
|
{ type: 'totp', label: 'TOTP' },
|
||||||
|
{ type: 'card', label: 'Card' },
|
||||||
|
{ type: 'identity', label: 'Identity' },
|
||||||
|
{ type: 'key', label: 'SSH / API Key' },
|
||||||
|
{ type: 'document', label: 'Document' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function render(ctx: VaultController): void {
|
||||||
|
const app = document.getElementById('vault-app');
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
if (!ctx.state.unlocked) {
|
||||||
|
renderLockScreen(ctx, app);
|
||||||
|
} else {
|
||||||
|
renderShell(ctx, app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lock screen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderErrorBlock(code: string | null | undefined): string {
|
||||||
|
if (!code) return '';
|
||||||
|
const copy = lookupErrorCopy(code);
|
||||||
|
const ctaHtml = copy.cta
|
||||||
|
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="error error-block">
|
||||||
|
<div class="error-title">${escapeHtml(copy.title)}</div>
|
||||||
|
<div class="error-body">${escapeHtml(copy.body)}</div>
|
||||||
|
${ctaHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLockScreen(ctx: VaultController, app: HTMLElement): void {
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="vault-lock-screen">
|
||||||
|
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||||
|
<span class="brand">Relicario</span>
|
||||||
|
<div class="vault-lock-screen__form">
|
||||||
|
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
||||||
|
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
||||||
|
${renderErrorBlock(ctx.state.error)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = document.getElementById('vault-passphrase') as HTMLInputElement;
|
||||||
|
const btn = document.getElementById('vault-unlock-btn')!;
|
||||||
|
|
||||||
|
const doUnlock = async () => {
|
||||||
|
const passphrase = input.value;
|
||||||
|
if (!passphrase) return;
|
||||||
|
btn.textContent = 'unlocking...';
|
||||||
|
btn.setAttribute('disabled', 'true');
|
||||||
|
const resp = await ctx.sendMessage({ type: 'unlock', passphrase });
|
||||||
|
if (resp.ok) {
|
||||||
|
ctx.state.unlocked = true;
|
||||||
|
ctx.state.error = null;
|
||||||
|
await ctx.loadManifest();
|
||||||
|
render(ctx);
|
||||||
|
} else {
|
||||||
|
ctx.state.error = resp.error ?? 'unlock failed';
|
||||||
|
render(ctx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.addEventListener('click', doUnlock);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') doUnlock();
|
||||||
|
});
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shell (3-column: sidebar + list pane + drawer)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function renderShell(ctx: VaultController, app: HTMLElement): void {
|
||||||
|
if (!app.querySelector('.vault-shell')) {
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="vault-shell">
|
||||||
|
${renderSidebarShell()}
|
||||||
|
<div class="vault-list-pane" id="vault-list-pane"></div>
|
||||||
|
<div class="vault-pane" id="vault-pane"></div>
|
||||||
|
<div class="vault-drawer" id="vault-drawer"></div>
|
||||||
|
<div class="vault-type-panel-scrim" id="vault-type-scrim"></div>
|
||||||
|
<aside class="vault-type-panel" id="vault-type-panel" aria-label="Choose item type"></aside>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
ctx.wireSidebar();
|
||||||
|
wireTypePanel(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyShellViewClass(ctx);
|
||||||
|
ctx.renderSidebarCategories();
|
||||||
|
if (ctx.state.view === 'list') {
|
||||||
|
ctx.renderListPane();
|
||||||
|
if (ctx.state.drawerOpen && ctx.state.selectedItem) {
|
||||||
|
ctx.renderDrawer(ctx.state.selectedItem);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.renderPane();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle which middle column is visible based on the current view.
|
||||||
|
// list view → list-pane (+ optional drawer); other views → vault-pane.
|
||||||
|
export function applyShellViewClass(ctx: VaultController): void {
|
||||||
|
const shell = document.querySelector('.vault-shell');
|
||||||
|
if (!shell) return;
|
||||||
|
shell.classList.toggle('vault-shell--list', ctx.state.view === 'list');
|
||||||
|
shell.classList.toggle('vault-shell--pane', ctx.state.view !== 'list');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Right-side type picker panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function wireTypePanel(ctx: VaultController): void {
|
||||||
|
document.getElementById('vault-type-scrim')?.addEventListener('click', () => closeTypePanel(ctx));
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && ctx.state.typePanelOpen) closeTypePanel(ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openTypePanel(ctx: VaultController): void {
|
||||||
|
const panel = document.getElementById('vault-type-panel');
|
||||||
|
const scrim = document.getElementById('vault-type-scrim');
|
||||||
|
if (!panel || !scrim) return;
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="vault-type-panel__head">
|
||||||
|
<div class="vault-type-panel__title">New item</div>
|
||||||
|
<button class="vault-type-panel__close" id="vault-type-close" title="Close (Esc)" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="vault-type-panel__hint">Choose a type</div>
|
||||||
|
<div class="vault-type-list" role="menu">
|
||||||
|
${PICKER_TYPES.map((t) => `
|
||||||
|
<button class="vault-type-item" data-type="${t.type}" role="menuitem">
|
||||||
|
<span class="vault-type-item__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
||||||
|
<span class="vault-type-item__name">${escapeHtml(t.label)}</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
panel.classList.add('vault-type-panel--open');
|
||||||
|
scrim.classList.add('vault-type-panel-scrim--visible');
|
||||||
|
ctx.state.typePanelOpen = true;
|
||||||
|
|
||||||
|
panel.querySelector('#vault-type-close')?.addEventListener('click', () => closeTypePanel(ctx));
|
||||||
|
|
||||||
|
panel.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const type = btn.dataset.type as ItemType;
|
||||||
|
closeTypePanel(ctx);
|
||||||
|
// Use the host's navigate hook so view + hash + visibility all update
|
||||||
|
// together. This was the bug: bare setHash + renderPane left the
|
||||||
|
// shell stuck in list view with #vault-pane hidden.
|
||||||
|
ctx.state.newType = type;
|
||||||
|
ctx.state.selectedId = null;
|
||||||
|
ctx.state.selectedItem = null;
|
||||||
|
ctx.state.drawerOpen = false;
|
||||||
|
ctx.state.view = 'add';
|
||||||
|
ctx.setHash('add', type);
|
||||||
|
applyShellViewClass(ctx);
|
||||||
|
ctx.renderSidebarCategories();
|
||||||
|
ctx.renderPane();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus first item for keyboard users
|
||||||
|
(panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeTypePanel(ctx: VaultController): void {
|
||||||
|
document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open');
|
||||||
|
document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible');
|
||||||
|
ctx.state.typePanelOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color scheme + session-expired wiring (bootstrap helpers)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function applyVaultColorScheme(): Promise<void> {
|
||||||
|
await applyColorScheme();
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||||
|
void applyColorScheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wireSessionExpiredListener(ctx: VaultController): void {
|
||||||
|
chrome.runtime.onMessage.addListener((msg) => {
|
||||||
|
if (msg.type === 'session_expired') {
|
||||||
|
ctx.state.unlocked = false;
|
||||||
|
ctx.state.selectedId = null;
|
||||||
|
ctx.state.selectedItem = null;
|
||||||
|
ctx.state.entries = [];
|
||||||
|
ctx.state.error = null;
|
||||||
|
render(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
190
extension/src/vault/vault-sidebar.ts
Normal file
190
extension/src/vault/vault-sidebar.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// Vault-tab sidebar column: its static markup, the category nav rendering,
|
||||||
|
// nav-button wiring, and the (now debounced) search input. Each function
|
||||||
|
// receives the VaultController (`ctx`) and reaches sibling concerns through it;
|
||||||
|
// pure helpers come from vault-context. Imports only from shared/ and
|
||||||
|
// vault-context, plus the leaf renderer vault-status — never from vault-shell
|
||||||
|
// or vault.ts.
|
||||||
|
|
||||||
|
import type { ItemType } from '../shared/types';
|
||||||
|
import {
|
||||||
|
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, GLYPH_REFRESH,
|
||||||
|
} from '../shared/glyphs';
|
||||||
|
import { renderStatusIndicator, type VaultStatus } from './vault-status';
|
||||||
|
import {
|
||||||
|
type VaultController, typeIcon, typeLabel, getFilteredEntries,
|
||||||
|
} from './vault-context';
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 80;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar markup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function renderSidebarShell(): string {
|
||||||
|
return `
|
||||||
|
<div class="vault-sidebar">
|
||||||
|
<div class="vault-sidebar__header">
|
||||||
|
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||||
|
<span class="brand">Relicario</span>
|
||||||
|
</div>
|
||||||
|
<div class="vault-sidebar__search">
|
||||||
|
<input type="text" id="vault-search" placeholder="/ search…" />
|
||||||
|
</div>
|
||||||
|
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
|
||||||
|
<div class="vault-sidebar__nav">
|
||||||
|
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</span></button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="vault-sidebar__footer">
|
||||||
|
<div id="vault-status-slot"></div>
|
||||||
|
<button class="vault-status-refresh" id="status-refresh-btn" type="button" title="Refresh status" aria-label="Refresh status">${GLYPH_REFRESH}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar wiring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function wireSidebar(ctx: VaultController): void {
|
||||||
|
// Search (debounced — trailing edge)
|
||||||
|
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
||||||
|
let searchTimer: number | undefined;
|
||||||
|
searchInput?.addEventListener('input', () => {
|
||||||
|
if (searchTimer !== undefined) clearTimeout(searchTimer);
|
||||||
|
searchTimer = window.setTimeout(() => {
|
||||||
|
ctx.state.searchQuery = searchInput.value;
|
||||||
|
renderSidebarCategories(ctx);
|
||||||
|
ctx.renderListPane();
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nav buttons
|
||||||
|
document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const nav = (btn as HTMLElement).dataset.nav;
|
||||||
|
if (nav === 'lock') {
|
||||||
|
await ctx.sendMessage({ type: 'lock' });
|
||||||
|
ctx.state.unlocked = false;
|
||||||
|
ctx.state.selectedId = null;
|
||||||
|
ctx.state.selectedItem = null;
|
||||||
|
ctx.state.entries = [];
|
||||||
|
ctx.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav === 'add') {
|
||||||
|
ctx.state.selectedId = null;
|
||||||
|
ctx.state.selectedItem = null;
|
||||||
|
ctx.state.newType = null;
|
||||||
|
ctx.state.drawerOpen = false;
|
||||||
|
ctx.closeDrawer();
|
||||||
|
ctx.openTypePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') {
|
||||||
|
ctx.state.selectedId = null;
|
||||||
|
ctx.state.selectedItem = null;
|
||||||
|
ctx.state.newType = null;
|
||||||
|
ctx.state.drawerOpen = false;
|
||||||
|
ctx.state.view = nav;
|
||||||
|
ctx.setHash(nav);
|
||||||
|
ctx.applyShellViewClass();
|
||||||
|
ctx.renderPane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global "/" shortcut to focus search; Esc to close drawer
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && ctx.state.drawerOpen) {
|
||||||
|
ctx.closeDrawer();
|
||||||
|
ctx.renderListPane();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vault status indicator — refresh on mount + on the manual button only.
|
||||||
|
// No timer polling: get_vault_status returns cached state and sync is
|
||||||
|
// user-initiated (spec 2026-05-04, Phase 6).
|
||||||
|
const refreshStatus = async (): Promise<void> => {
|
||||||
|
const resp = await ctx.sendMessage({ type: 'get_vault_status' });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const slot = document.getElementById('vault-status-slot');
|
||||||
|
if (slot) renderStatusIndicator(slot, resp.data as VaultStatus);
|
||||||
|
};
|
||||||
|
void refreshStatus();
|
||||||
|
document.getElementById('status-refresh-btn')?.addEventListener('click', () => {
|
||||||
|
void refreshStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar category nav
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function renderSidebarCategories(ctx: VaultController): void {
|
||||||
|
const container = document.getElementById('vault-categories');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const filtered = getFilteredEntries(ctx.state);
|
||||||
|
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
||||||
|
|
||||||
|
const allCount = filtered.length;
|
||||||
|
const isAllActive = !ctx.state.activeGroup && ctx.state.view === 'list';
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
|
||||||
|
<span class="vault-category-row__icon">◈</span>
|
||||||
|
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
|
||||||
|
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const t of typeOrder) {
|
||||||
|
const count = filtered.filter(([, e]) => e.type === t).length;
|
||||||
|
// Always show Login (staple type); hide other types when empty.
|
||||||
|
if (count === 0 && t !== 'login') continue;
|
||||||
|
const isActive = ctx.state.activeGroup === t;
|
||||||
|
html += `
|
||||||
|
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
||||||
|
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
||||||
|
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
||||||
|
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
ctx.state.activeGroup = btn.dataset.group || null;
|
||||||
|
ctx.state.drawerOpen = false;
|
||||||
|
ctx.state.selectedId = null;
|
||||||
|
ctx.state.selectedItem = null;
|
||||||
|
ctx.state.view = 'list';
|
||||||
|
ctx.setHash('list');
|
||||||
|
ctx.applyShellViewClass();
|
||||||
|
renderSidebarCategories(ctx);
|
||||||
|
ctx.renderListPane();
|
||||||
|
ctx.closeDrawer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
33
extension/src/vault/vault-status.ts
Normal file
33
extension/src/vault/vault-status.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
GLYPH_SYNCED,
|
||||||
|
GLYPH_AHEAD,
|
||||||
|
GLYPH_BEHIND,
|
||||||
|
GLYPH_PENDING,
|
||||||
|
} from '../shared/glyphs';
|
||||||
|
import { relativeTime } from '../shared/relative-time';
|
||||||
|
import type { GetVaultStatusResponse } from '../shared/messages';
|
||||||
|
|
||||||
|
// The indicator consumes exactly the get_vault_status response payload; alias
|
||||||
|
// it (rather than re-declaring the four fields) so the shape stays single-
|
||||||
|
// sourced and can't drift from the SW handler. lastSyncAt is a unix timestamp
|
||||||
|
// in SECONDS, or null when the vault has never synced.
|
||||||
|
export type VaultStatus = GetVaultStatusResponse['data'];
|
||||||
|
|
||||||
|
export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void {
|
||||||
|
const ts = status.lastSyncAt !== null
|
||||||
|
? `last sync ${relativeTime(status.lastSyncAt)}`
|
||||||
|
: 'never synced';
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (status.pendingItems > 0) parts.push(`${GLYPH_PENDING} ${status.pendingItems} pending`);
|
||||||
|
if (status.ahead > 0) parts.push(`${GLYPH_AHEAD} ${status.ahead} ahead`);
|
||||||
|
if (status.behind > 0) parts.push(`${GLYPH_BEHIND} ${status.behind} behind`);
|
||||||
|
if (parts.length === 0) parts.push(`${GLYPH_SYNCED} in sync`);
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="vault-status">
|
||||||
|
<div class="vault-status__state">${parts.join(' · ')}</div>
|
||||||
|
<div class="vault-status__ts">${ts}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -2113,3 +2113,39 @@ textarea {
|
|||||||
.history-index-row__info { flex: 1; display: flex; flex-direction: column; }
|
.history-index-row__info { flex: 1; display: flex; flex-direction: column; }
|
||||||
.history-index-row__title { color: var(--text); }
|
.history-index-row__title { color: var(--text); }
|
||||||
.history-index-row__meta { font-size: 11px; }
|
.history-index-row__meta { font-size: 11px; }
|
||||||
|
|
||||||
|
/* Sidebar-footer vault status indicator (Plan C Phase 6, vault-status.ts +
|
||||||
|
vault-sidebar.ts). Indicator renders into #vault-status-slot; the ↻ button
|
||||||
|
triggers a manual refresh (no timer polling). */
|
||||||
|
.vault-sidebar__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
#vault-status-slot { flex: 1; min-width: 0; }
|
||||||
|
.vault-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.vault-status__state { color: var(--text-dim); }
|
||||||
|
.vault-status__ts { color: var(--text-muted); }
|
||||||
|
.vault-status-refresh {
|
||||||
|
flex: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.vault-status-refresh:hover { color: var(--text); background: var(--bg-input); }
|
||||||
|
.vault-status-refresh:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
3
tools/relay/.gitignore
vendored
Normal file
3
tools/relay/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Runtime message archive written by queue.ts post() — local relay traffic,
|
||||||
|
# not source. Regenerated each session; never committed.
|
||||||
|
relay-log.jsonl
|
||||||
49
tools/relay/pm
Executable file
49
tools/relay/pm
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PM relay helper — absolute-path wrapper around call.py so it can be invoked
|
||||||
|
# from ANY working directory with no `cd` and no JSON-quoting by hand.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# tools/relay/pm read # drain PM inbox
|
||||||
|
# tools/relay/pm pending # pending counts for all roles
|
||||||
|
# tools/relay/pm send <to> <kind> <body> # post_message from pm
|
||||||
|
# e.g. tools/relay/pm send dev-c directive "## DIRECTIVE ... "
|
||||||
|
#
|
||||||
|
# Always works regardless of cwd because it resolves call.py by absolute path.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RELAY_DIR="/home/alee/Sources/relicario/tools/relay"
|
||||||
|
CALL="python3 $RELAY_DIR/call.py"
|
||||||
|
|
||||||
|
cmd="${1:-}"
|
||||||
|
case "$cmd" in
|
||||||
|
read)
|
||||||
|
$CALL read_messages '{"for":"pm"}'
|
||||||
|
;;
|
||||||
|
pending)
|
||||||
|
for r in dev-a dev-b dev-c pm; do
|
||||||
|
printf '%s: ' "$r"
|
||||||
|
$CALL list_pending "{\"for\":\"$r\"}"
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
send)
|
||||||
|
to="${2:?usage: pm send <to> <kind> <body>}"
|
||||||
|
kind="${3:?usage: pm send <to> <kind> <body>}"
|
||||||
|
body="${4:?usage: pm send <to> <kind> <body>}"
|
||||||
|
# Build JSON with python to handle escaping of the body safely.
|
||||||
|
python3 - "$to" "$kind" "$body" <<'PY'
|
||||||
|
import json, sys, urllib.request
|
||||||
|
to, kind, body = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
payload = {"from": "pm", "to": to, "kind": kind, "body": body}
|
||||||
|
import subprocess
|
||||||
|
print(subprocess.run(
|
||||||
|
["python3", "/home/alee/Sources/relicario/tools/relay/call.py",
|
||||||
|
"post_message", json.dumps(payload)],
|
||||||
|
capture_output=True, text=True).stdout, end="")
|
||||||
|
PY
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: pm {read|pending|send <to> <kind> <body>}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -52,7 +52,10 @@ describe("RelayQueue", () => {
|
|||||||
assert.ok(isRole("dev-a"));
|
assert.ok(isRole("dev-a"));
|
||||||
assert.ok(isRole("dev-b"));
|
assert.ok(isRole("dev-b"));
|
||||||
assert.ok(isRole("dev-c"));
|
assert.ok(isRole("dev-c"));
|
||||||
assert.ok(!isRole("dev-d"));
|
assert.ok(isRole("dev-d"));
|
||||||
|
assert.ok(isRole("dev-e"));
|
||||||
|
assert.ok(isRole("dev-f"));
|
||||||
|
assert.ok(!isRole("dev-g"));
|
||||||
assert.ok(!isRole(""));
|
assert.ok(!isRole(""));
|
||||||
assert.ok(!isRole("PM"));
|
assert.ok(!isRole("PM"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { appendFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c";
|
// Append-only archive of every posted message. The in-memory queues are
|
||||||
|
// consume-once (read() drains the inbox) and vanish on restart, so this is
|
||||||
|
// the only durable, full-body record of relay traffic. One JSON object per
|
||||||
|
// line; never truncated.
|
||||||
|
const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), "relay-log.jsonl");
|
||||||
|
|
||||||
|
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c" | "dev-d" | "dev-e" | "dev-f";
|
||||||
export type MessageKind = "status" | "question" | "directive" | "free";
|
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||||
|
|
||||||
export interface RelayMessage {
|
export interface RelayMessage {
|
||||||
@@ -12,7 +21,7 @@ export interface RelayMessage {
|
|||||||
ts: string;
|
ts: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c"]);
|
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"]);
|
||||||
|
|
||||||
export function isRole(s: string): s is Role {
|
export function isRole(s: string): s is Role {
|
||||||
return KNOWN_ROLES.has(s);
|
return KNOWN_ROLES.has(s);
|
||||||
@@ -24,6 +33,9 @@ export class RelayQueue {
|
|||||||
["dev-a", []],
|
["dev-a", []],
|
||||||
["dev-b", []],
|
["dev-b", []],
|
||||||
["dev-c", []],
|
["dev-c", []],
|
||||||
|
["dev-d", []],
|
||||||
|
["dev-e", []],
|
||||||
|
["dev-f", []],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||||
@@ -36,6 +48,11 @@ export class RelayQueue {
|
|||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
this.queues.get(to)!.push(msg);
|
this.queues.get(to)!.push(msg);
|
||||||
|
try {
|
||||||
|
appendFileSync(LOG_PATH, JSON.stringify(msg) + "\n");
|
||||||
|
} catch {
|
||||||
|
// Logging is best-effort; never let a disk error drop a message.
|
||||||
|
}
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
from: {
|
from: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||||
description: "Your role name",
|
description: "Your role name",
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||||
description: "Recipient role name",
|
description: "Recipient role name",
|
||||||
},
|
},
|
||||||
kind: {
|
kind: {
|
||||||
@@ -50,7 +50,7 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
for: {
|
for: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||||
description: "Your role name",
|
description: "Your role name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
for: {
|
for: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
|
||||||
description: "Your role name",
|
description: "Your role name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -86,8 +86,8 @@ function handleToolCall(name: string, args: Record<string, string>) {
|
|||||||
const kind = args.kind as "status" | "question" | "directive" | "free";
|
const kind = args.kind as "status" | "question" | "directive" | "free";
|
||||||
const msg = queue.post(args.from, args.to, kind, args.body);
|
const msg = queue.post(args.from, args.to, kind, args.body);
|
||||||
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||||
const preview = args.body.slice(0, 60).replace(/\n/g, " ");
|
const preview = args.body.slice(0, 120).replace(/\n/g, " ");
|
||||||
const ellipsis = args.body.length > 60 ? "..." : "";
|
const ellipsis = args.body.length > 120 ? "..." : "";
|
||||||
process.stdout.write(`[${ts}] ${args.from} → ${args.to} [${kind}] "${preview}${ellipsis}"\n`);
|
process.stdout.write(`[${ts}] ${args.from} → ${args.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||||
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user