Compare commits

...
2 Commits
9 changed files with 721 additions and 4 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
export * from "../../organization/src/accounts.mjs";
+29
View File
@@ -1298,3 +1298,32 @@ shows selected, active and inactive ids. The owner replaces the selection with
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
empty `--types ''` clears it. Disabled selections stay recorded but become
inactive. Project settings changes are append-only and require human proof.
## Accounts
`mosaic accounts` administers the instance-level accounts store locally, as the
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
and takes no business selector or bus connection. It has no organization rights
verbs. Console sign-in, sessions and membership evaluation are later Phase M
slices; these commands alone do not turn accounts mode on.
```sh
mosaic accounts init --name Operator
mosaic accounts list --json
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
mosaic accounts user disable u-0123456789ab --revision 2
mosaic accounts user enable u-0123456789ab --revision 3
mosaic accounts user signout u-0123456789ab --revision 4
mosaic accounts user remove u-0123456789ab --revision 5
```
Init creates one operator account containing every configured `console.sso`
subject and refuses if accounts.json already exists. Add mints its id and defaults
to instance role `user`; `--role operator` creates another operator. Writes require
the file revision from list. Disable, enable and signout advance `authGen`, and
the last active operator cannot be disabled or removed. Removal scans ownership
while holding the registry then accounts locks, refuses if the account owns an
organization (including archived organizations) or any registry entry lacks its
owner metadata during creation, and removes its memberships in
the same write. List reports rollback rows needing repair; its JSON omits invite
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
+109
View File
@@ -0,0 +1,109 @@
// Phase M2: the local, instance-uid account administration exception. No org
// rights commands and no bus transport: account init must work before orgs exist.
import { basename, join } from 'node:path';
import { configDir } from '../../organization/src/organization-config.mjs';
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ACCOUNTS_USAGE = [
'mosaic accounts init [--name NAME]',
'mosaic accounts list [--json]',
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
'mosaic accounts user disable|enable|signout|remove ID --revision N',
].join('\n');
function parse(args) {
const words = [], values = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('--')) { words.push(arg); continue; }
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
if (arg === '--json') flags.add(arg);
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
else throw new CliError(ACCOUNTS_USAGE, 4);
}
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
const revision = verb === 'init' ? 0 : Number(values['--revision']);
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
return { verb, values, flags, revision, id: words[2] };
}
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
return owner;
}
export async function accountsCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
const parsed = parse(args);
const dir = deps.configDir ?? configDir(io.env);
// This is deliberately before withRegistry as well as withAccounts.
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
const providers = deps.system.console?.sso?.providers ?? [];
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
if (parsed.verb === 'list') {
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
const repairs = accountsRepairs(doc);
// Invites are not administered here. Never expose bindings or token hashes.
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
return;
}
let added;
const mutate = doc => {
if (parsed.verb === 'init') {
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
if (parsed.verb === 'user.add') {
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
const u = doc.users.find(u => u.id === parsed.id);
if (!u) throw new CliError('refused: account not found', 2);
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
else if (parsed.verb === 'user.signout') u.authGen++;
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
};
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
let result;
if (parsed.verb === 'user.remove') {
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
const user = doc.users.find(u => u.id === parsed.id);
if (!user) throw new CliError('refused: account not found', 2);
// Even disabled accounts keep ownership. A real create reserves before
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
mutate(doc);
}), { write: false });
} else result = await withAccounts(ctx, opts, mutate);
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
} catch (e) {
if (e instanceof CliError) throw e;
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
if (e.committed) error.committed = true;
throw error;
}
}
+3
View File
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError, CliExit } from "./errors.mjs";
@@ -52,6 +53,7 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
export const USAGE = `usage:
${ACCOUNTS_USAGE}
${ORG_USAGE}
${USAGE_USAGE}
${LIMITS_USAGE}
@@ -745,6 +747,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
now: deps.now,
});
}
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
import { main } from '../src/cli.mjs';
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
function setup(t) {
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
}
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
});
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
});
test('host uid refusal happens before any lock, even on list or remove', async t => {
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
}
});
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
});
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
});
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
});
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
});
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
process.getuid = () => effectiveUid + 1;
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
finally { process.getuid = realUid; }
});
+44 -2
View File
@@ -7,8 +7,9 @@ stay as the old names: each module there re-exports the module of the same name
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
addenda A and B. The package reads files and refuses on any problem. It
never writes, and it never opens a token file.
addenda A and B. Configuration readers refuse on any problem. Runtime
organization and accounts stores provide explicit locked writes; this package
never opens a token file.
```sh
scripts/mosaic org config validate mosaic-stack
@@ -583,3 +584,44 @@ The optional `plugins.system-type` list enables installed system-type modules fo
an organization. Missing means none. Unknown ids refuse validation. Changes
are owner-only; project selections are separate bus settings and a selection
whose type is disabled remains recorded as inactive.
## Instance accounts (Phase M2)
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
configured `console.sso.providers` objects (including issuer). Reads refuse an
unsafe config directory, unsafe file, unknown field or malformed row. Account
ids may be organization humans. `accountsRepairs(document)` reports unknown
roles and rights retained on read for rollback; these rows must grant nothing.
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
revision 0 and an absent file. Other writes require an existing valid file. Writes
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
For a caller already holding accounts.lock,
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
reuses that live opaque handle. Never acquire an earlier lock from the callback.
The config path must have no symlink ancestors, including the home directory.
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
the lock and rereads its persisted snapshot without relocking. A changed registry
or an instance mismatch refuses before account publication. Callbacks cannot
reuse a released handle, or run two writes at once through one handle.
Each committed write appends and fsyncs one receipt under the accounts lock in
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
account ids with changed field names, and before/after membership rights, scope
and revisions. No account
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
store preflights the receipt destination before replacing accounts. As with
other two-file journals, a crash or IO failure between replacement and receipt
append can leave committed state without its receipt; an observed append failure
has `committed: true` and must be inspected before retrying. It never rolls back
committed authority silently or fabricates a success receipt.
+266
View File
@@ -0,0 +1,266 @@
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
import { constants, lstatSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, join, parse, resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
import { OrgError, readRegistryLocked } from './org-registry.mjs';
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
const fail = (code, message = code) => { throw new OrgError(code, message); };
const bad = message => fail('accounts-invalid', message);
const USER_ID = /^u-[0-9a-f]{12}$/;
const HEX32 = /^[0-9a-f]{32}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
const text = (x, at) => {
requireString(x, at, { max: 255 });
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
return x;
};
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
function time(x, at) {
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
}
function providerMap(context) {
const providers = new Map();
for (const p of array(context?.providers, 'accounts context.providers')) {
text(p.id, 'provider.id');
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
if (providers.has(p.id)) bad('duplicate configured provider');
providers.set(p.id, p);
}
return providers;
}
function subject(s, at, providers) {
keys(s, ['provider', 'sub'], at);
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
text(s.sub, `${at}.sub`);
}
function scope(s, at) {
keys(s, ['projects', 'instances', 'expires'], at);
for (const key of ['projects', 'instances']) {
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
if (x === '*') return;
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
else requireId(x, `${at}.instances`);
}, { nonEmpty: true });
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
}
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
}
function rowRights(row, at) {
requireId(row.role, `${at}.role`);
if (row.role === 'owner') bad(`${at}.role is not grantable`);
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
text(r, `${at}.rights`);
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
}, { nonEmpty: true });
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
scope(row.scope, `${at}.scope`);
requireId(row.org, `${at}.org`);
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
}
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
// accountsRepairs exposes them without changing the persisted schema.
export function checkAccounts(document, context) {
const providers = providerMap(context);
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
requirePositiveInt(document.revision, 'revision');
const users = new Set(), subjects = new Set(); let operators = 0;
array(document.users, 'users').forEach((u, i) => {
const at = `users[${i}]`;
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
text(u.name, `${at}.name`);
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
});
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
});
if (!operators) bad('at least one active operator must exist');
const memberships = new Set();
array(document.memberships, 'memberships').forEach((m, i) => {
const at = `memberships[${i}]`;
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
});
const invites = new Set(), hashes = new Set();
array(document.invites, 'invites').forEach((invite, i) => {
const at = `invites[${i}]`;
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
requireObject(invite.bind, `${at}.bind`);
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
else subject(invite.bind, `${at}.bind`, providers);
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
});
return deepFreeze(structuredClone(document));
}
export function accountsRepairs(doc) {
const result = [];
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
}
return result;
}
// Check the host boundary before any read or lock. Every ancestor must be a real
// directory; configDir itself belongs to this uid and is not publicly writable.
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part); const st = lstatSync(current);
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
}
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
assertAccountsOwner(dirname(file), { uid });
const doc = readJsonFile(file, 'accounts file', st => {
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
});
return checkAccounts(doc, context);
}
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
const heldAccounts = new WeakMap();
// Low-level helper for callers already holding the earlier lifecycle/registry
// locks. A handle is valid only within this callback and serializes all writes.
export async function withAccountsLock(ctx, fn) {
assertAccountsOwner(ctx.configDir);
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
for (let n = 0; n < 200; n++) {
try { fd = await fs.open(lock, 'wx', 0o600); break; }
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
}
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
try { return await fn(handle); }
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
}
export async function withAccounts(ctx, options, fn) {
if (options.registry) await readRegistryLocked(options.registry, ctx);
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
}
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
function generations(next, before, verb) {
for (const u of next.users) {
const old = before?.users.find(x => x.id === u.id);
if (!old) { u.authGen = 1; continue; }
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
if (changed) u.authGen = old.authGen + 1;
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
if (u.created !== old.created) bad('account created time is immutable');
}
for (const m of next.memberships) {
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
// A rollback row grants nothing, but must not block unrelated account
// revocation or the repair of another row. Preserve unchanged rows exactly.
if (old && same(m, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
m.rights = [...normalizeRights(m.rights)];
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
}
for (const i of next.invites) {
const old = before?.invites.find(x => x.id === i.id);
if (old && same(i, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
i.rights = [...normalizeRights(i.rights)];
}
}
// Receipt construction has no free-form text: no names, subjects, bindings,
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
function receipt(before, after, { verb, actor }, at) {
if (!VERBS.has(verb)) bad('unsupported receipt verb');
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
userChanges: after.users.flatMap(u => {
const old = before?.users.find(x => x.id === u.id);
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
return changed.length ? [{ id: u.id, changed }] : [];
}),
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
}
async function syncDirectory(dir) {
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { await fd.sync(); } finally { await fd.close(); }
}
// Re-read under the lock, compare expected revision, validate and atomically
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
// A post-rename receipt failure reports committed=true: retrying is not safe.
export async function withAccountsLocked(handle, options, fn) {
const held = heldAccounts.get(handle);
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
held.writing = true;
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
let temp, receiptFd, committed = false;
try {
if (options.registry) await readRegistryLocked(options.registry, ctx);
assertAccountsOwner(ctx.configDir);
let before;
if (options.init) {
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
if (options.revision !== 0) fail('revision-conflict');
} else before = loadAccounts(file, ctx);
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
await fn(next);
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
const checked = checkAccounts(next, ctx);
const line = receipt(before, checked, options, new Date().toISOString());
// Refuse unsafe receipt destinations before publishing the account change.
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const st = await receiptFd.stat();
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
const fd = await fs.open(temp, 'wx', 0o600);
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
await options.beforeReplace?.();
if (options.registry) await readRegistryLocked(options.registry, ctx);
// Defend against a non-cooperating writer even though cooperative writers
// already serialize through accounts.lock.
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
// Init must never replace a target, including one created by a writer that
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
if (before) await fs.rename(temp, file);
else await fs.link(temp, file);
committed = true;
if (!before) await fs.unlink(temp);
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
return checked;
} catch (e) { if (committed) e.committed = true; throw e; }
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
}
+17 -2
View File
@@ -36,14 +36,29 @@ export async function readRegistry({ configDir }) {
return validateRegistry(doc, file);
}
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
const heldRegistries = new WeakMap();
// Only a live withRegistry document may be reused by a later accounts-lock
// holder. Recheck the persisted snapshot without reacquiring this lock.
export async function readRegistryLocked(registry, ctx) {
const held = heldRegistries.get(registry);
if (!held) fail('org-locked', 'a live registry lock document is required');
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
const current = await readRegistry(held.ctx);
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
}
export async function withRegistry(ctx, fn, { write = true } = {}) {
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
if (!fd) fail('org-locked', 'registry lock is held', lock);
try {
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
const persisted = await readRegistry(ctx);
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
const result = await fn(registry);
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
let result;
try { result = await fn(registry); }
finally { heldRegistries.delete(registry); }
if (write) {
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { templateRender } from '../src/org-service.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { REPO_ROLES } from './helpers.mjs';
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
export const OP = 'u-000000000001', USER = 'u-000000000002';
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
function setup(t, doc = document()) {
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
return { configDir: dir, instance: 'test', ...context, file };
}
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
});
test('unknown role and right load as repair rows; account may be a business human', () => {
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
});
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
}
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
});
test('rights/scope revisions and security generations are maintained by the writer', async t => {
const d = document(); d.memberships.push(member()); const s = setup(t, d);
let rev = 1;
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
}
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
assert.equal(result.users[1].authGen, 6);
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
assert.equal(statSync(s.file).mode & 0o777, 0o600);
});
test('concurrent writes serialize and the later stale write changes nothing', async t => {
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
assert.equal(loadAccounts(s.file, s).revision, 2);
});
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
const s = setup(t); let held;
await assert.rejects(readRegistryLocked({}), /lock/);
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
await withRegistry(s, async registry => {
assert.equal(await readRegistryLocked(registry), registry);
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
});
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
const before = readFileSync(s.file, 'utf8');
await withRegistry(s, async registry => {
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
}, { write: false });
assert.equal(readFileSync(s.file, 'utf8'), before);
});
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
const s = setup(t); const secret = 'subject-secret';
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
});
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
});
function invitation() {
const { user: ignored, rev, added, via, ...row } = member();
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
}
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
}
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
});
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
});
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
const s = setup(t), other = setup(t);
await withAccountsLock(s, async handle => {
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
});
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
assert.equal(loadAccounts(s.file, s).revision, 3);
});
test('invite token hashes and subject bindings never reach receipts', async t => {
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
});
test('a failed rename preserves published bytes and appends no receipt', async t => {
const s = setup(t), before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
});
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
});
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
assert.throws(() => checkAccounts(d, context));
}
});
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
});
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
});
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
const s = setup(t); let error;
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
});