refactor(persistence): migrate invite-store to canonical atomic-write util (DC-100) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

Drops invite-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js:
fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync,
cleanup-on-failure. Sole format consumer is a JSON.parse reader, so the
dropped trailing newline is unobservable. +2 store-level regression tests
pin 0600 / complete JSON / no temp leftovers under burst mutations.
Judge: GLM-5.3 cold read, round-1 A, deleg_7177d506.
Verdict: urn:ump:4kwenywtf3nx2mokobvuzrhklk2xigmpyea6nvqdyhv7icnflpkq
Full suite: 121 suites / 2770 tests green.
This commit is contained in:
Hermes
2026-08-22 23:54:37 -07:00
parent 80a82c4cae
commit 3742e2658d
2 changed files with 43 additions and 8 deletions
@@ -196,3 +196,42 @@ describe('DC-099 atomic-write syscall discipline (mocked fs)', () => {
expect(calls).toEqual(['openSync', 'unlinkSync']);
});
});
// DC-100: invite-store migrated off its private _atomicWriteJSON copy onto
// the canonical writer. Store-level pins: writes are durable-canonical
// (0600, complete JSON, no temp leftovers) even under back-to-back mutations
// — the access pattern that could collide tmp names in the naive copy.
describe('DC-100 invite-store on canonical atomic-write (real fs)', () => {
let dir, store;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc100-invite-'));
store = require('../src/security/invite-store').createInviteStore({ dataDir: dir });
});
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
test('issued invite lands as complete JSON at mode 0600 with no temp leftovers', async () => {
const r = await store.issue({ email: 'dc100@x.com', ttlMs: 60_000 });
expect(r.ok).toBe(true);
const file = path.join(dir, 'invites.json');
const st = fs.statSync(file);
expect(st.mode & 0o777).toBe(0o600);
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(Object.keys(data.invites)).toHaveLength(1);
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
expect(leftovers).toEqual([]);
});
test('back-to-back mutations (issue, revoke, issue) never collide on tmp names', async () => {
const a = await store.issue({ email: 'a@x.com', ttlMs: 60_000 });
const b = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
await store.revoke(a.id);
const c = await store.issue({ email: 'c@x.com', ttlMs: 60_000 });
expect(b.ok).toBe(true);
expect(c.ok).toBe(true);
const data = JSON.parse(fs.readFileSync(path.join(dir, 'invites.json'), 'utf8'));
expect(Object.keys(data.invites).sort()).toEqual([b.id, c.id].sort());
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
expect(leftovers).toEqual([]);
});
});
+4 -8
View File
@@ -5,7 +5,8 @@
* the system emails (or logs in dev) a magic-link-style URL containing the
* raw token. The recipient clicks → accepts → becomes an authorized user.
*
* Storage: data/invites.json. Atomic writes via tmp+rename.
* Storage: data/invites.json. Atomic durable writes via the canonical
* shared atomic-write util (DC-099/DC-100) — fsync'd tmp+rename.
*
* Token shape:
* - 32 random bytes, base64url-encoded (256 bits of entropy).
@@ -30,6 +31,7 @@ const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const { atomicWriteJSON } = require('../utils/atomic-write');
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d
@@ -37,12 +39,6 @@ const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after
function _nowMs() { return Date.now(); }
function _nowIso() { return new Date().toISOString(); }
function _atomicWriteJSON(filePath, data) {
const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function _readJSON(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
@@ -85,7 +81,7 @@ function createInviteStore(opts = {}) {
if (!data.invites || typeof data.invites !== 'object') data.invites = {};
return data;
}
function _save(data) { _atomicWriteJSON(file, data); }
function _save(data) { atomicWriteJSON(file, data); }
function _prune(data) {
const cutoff = _nowMs() - PRUNE_AFTER_MS;