diff --git a/dashcaddy-api/__tests__/atomic-write-dc099.test.js b/dashcaddy-api/__tests__/atomic-write-dc099.test.js index 3157c0b..84e9d3b 100644 --- a/dashcaddy-api/__tests__/atomic-write-dc099.test.js +++ b/dashcaddy-api/__tests__/atomic-write-dc099.test.js @@ -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([]); + }); +}); diff --git a/dashcaddy-api/src/security/invite-store.js b/dashcaddy-api/src/security/invite-store.js index b4d161d..85f68d0 100644 --- a/dashcaddy-api/src/security/invite-store.js +++ b/dashcaddy-api/src/security/invite-store.js @@ -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;