Feature/224 notification impl #265

Merged
bermudalamb merged 5 commits from feature/224-notification-impl into main 2026-09-01 15:47:28 -05:00
2 changed files with 160 additions and 0 deletions
Showing only changes of commit acc1b406d8 - Show all commits
+75
View File
@@ -0,0 +1,75 @@
import crypto from 'crypto';
import { trimTrailingSlashes } from '../utils';
/**
* Links in the notification email that act without a login.
*
* Only two actions are signable, and neither can publish. The worst case of a
* leaked link is a wasted API call or a hide the review queue can undo — which
* is what makes it acceptable to put them in an inbox at all.
*
* Signed over the item, the action and the expiry together. Signing any subset
* would let a link be replayed against a different item or upgraded to a
* different action, and leaving the expiry out of the payload would let anyone
* holding an expired link extend it by editing the timestamp in the URL.
*/
export type IntakeAction = 'regenerate' | 'discard';
/** Thirty days. Long enough to survive a holiday, short enough to lapse. */
export const ACTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function secret(): string | null {
const value = process.env.INTAKE_ACTION_SECRET;
return value && value.trim() !== '' ? value : null;
}
export function signAction(itemId: number, action: IntakeAction, expiresAt: number): string {
const key = secret();
if (!key) throw new Error('INTAKE_ACTION_SECRET is not set');
return crypto
.createHmac('sha256', key)
.update(`${itemId}:${action}:${expiresAt}`)
.digest('base64url');
}
/**
* Compared through a second digest rather than directly, because
* timingSafeEqual throws when the two buffers differ in length — and a
* malformed signature from a truncated link is an ordinary thing to receive
* rather than an exception. Same idiom as middleware/adminGate.ts.
*/
function digest(value: string): Buffer {
return crypto.createHash('sha256').update(value).digest();
}
export function verifyAction(
itemId: number,
action: IntakeAction,
expiresAt: number,
signature: string,
now: number = Date.now()
): boolean {
if (!secret()) return false;
if (!Number.isFinite(expiresAt) || now > expiresAt) return false;
const expected = signAction(itemId, action, expiresAt);
return crypto.timingSafeEqual(digest(expected), digest(signature));
}
/**
* The absolute link, or null when one cannot be made.
*
* Null rather than a throw or a relative path. An unconfigured environment
* still sends the notification with its review link — being told an item
* arrived matters far more than the shortcuts — and a link that could not be
* verified must never be offered in the first place.
*/
export function actionUrl(itemId: number, action: IntakeAction): string | null {
const base = process.env.PUBLIC_URL;
if (!secret() || !base || base.trim() === '') return null;
const expiresAt = Date.now() + ACTION_TTL_MS;
const sig = signAction(itemId, action, expiresAt);
const origin = trimTrailingSlashes(base);
return `${origin}/api/intake-actions/${itemId}/${action}?expires=${expiresAt}&sig=${sig}`;
}
+85
View File
@@ -0,0 +1,85 @@
import { signAction, verifyAction, actionUrl, ACTION_TTL_MS } from '../../src/intake/actionLinks';
const SECRET = 'test-intake-secret';
const NOW = 1_800_000_000_000;
const EXPIRY = NOW + ACTION_TTL_MS;
beforeEach(() => {
process.env.INTAKE_ACTION_SECRET = SECRET;
process.env.PUBLIC_URL = 'https://shop.example.com';
});
describe('signAction / verifyAction', () => {
it('accepts a signature it produced', () => {
const sig = signAction(7, 'discard', EXPIRY);
expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(true);
});
// Each of these is a different link. A signature that survives any of these
// swaps is a signature that authorises more than it names.
it('refuses a signature reused for another item', () => {
const sig = signAction(7, 'discard', EXPIRY);
expect(verifyAction(8, 'discard', EXPIRY, sig, NOW)).toBe(false);
});
it('refuses a signature reused for another action', () => {
const sig = signAction(7, 'discard', EXPIRY);
expect(verifyAction(7, 'regenerate', EXPIRY, sig, NOW)).toBe(false);
});
// Otherwise the expiry is decoration: anyone holding an expired link could
// extend it themselves by editing the timestamp.
it('refuses a signature whose expiry was altered', () => {
const sig = signAction(7, 'discard', EXPIRY);
expect(verifyAction(7, 'discard', EXPIRY + 1000, sig, NOW)).toBe(false);
});
it('refuses an expired link even with a valid signature', () => {
const sig = signAction(7, 'discard', EXPIRY);
expect(verifyAction(7, 'discard', EXPIRY, sig, EXPIRY + 1)).toBe(false);
});
it('refuses a malformed signature without throwing', () => {
expect(verifyAction(7, 'discard', EXPIRY, 'not-a-signature', NOW)).toBe(false);
expect(verifyAction(7, 'discard', EXPIRY, '', NOW)).toBe(false);
});
// This is what makes rotating the secret revoke every outstanding link.
it('refuses a signature made with a different secret', () => {
const sig = signAction(7, 'discard', EXPIRY);
process.env.INTAKE_ACTION_SECRET = 'a-different-secret';
expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(false);
});
it('refuses everything when there is no secret at all', () => {
const sig = signAction(7, 'discard', EXPIRY);
delete process.env.INTAKE_ACTION_SECRET;
expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(false);
});
});
describe('actionUrl', () => {
it('builds an absolute url carrying the expiry and signature', () => {
const url = actionUrl(7, 'discard');
expect(url).toContain('https://shop.example.com/api/intake-actions/7/discard');
expect(url).toMatch(/expires=\d+/);
expect(url).toMatch(/sig=[A-Za-z0-9_-]+/);
});
// Absent secret is a working configuration: the notification still sends with
// its review link. A link that cannot be verified must never be offered.
it('returns null when there is no secret', () => {
delete process.env.INTAKE_ACTION_SECRET;
expect(actionUrl(7, 'discard')).toBeNull();
});
it('returns null when there is no public url to build against', () => {
delete process.env.PUBLIC_URL;
expect(actionUrl(7, 'discard')).toBeNull();
});
it('does not double the slash when PUBLIC_URL has a trailing one', () => {
process.env.PUBLIC_URL = 'https://shop.example.com/';
expect(actionUrl(7, 'discard')).toContain('https://shop.example.com/api/intake-actions/');
});
});