refactor(persistence): migrate invite-store to canonical atomic-write util (DC-100) [glm-grade=A]
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user