Files
redefined-designs/backend/src/server.ts
T
bermudalamb 3374d093f0 feat(backend): add an application-layer gate to the admin API (#63)
Authorization for the admin panel and the admin API has lived entirely in one auth_request regex in an Nginx Proxy Manager config outside this repository. That control is real and it works — nothing is publicly exposed today — but it is invisible from the code, untested here, and not reviewed when this code changes. Three things follow from that, and the first is the one worth the change.

An admin route added at a path the regex does not match is unprotected the moment it is written, and nothing in Express indicates that. Anything reaching the published container port directly bypasses authentik entirely. And locally there is no gate at all, so no developer ever sees the boundary being enforced.

requireAdminGate is attached to each admin router rather than to a path prefix, which is what makes it useful rather than merely redundant with the proxy. An admin router added later at some other path inherits the gate; because the proxy only injects the header on paths its regex matches, that router refuses on its first request instead of being quietly public. A 403 in that situation is the boundary reporting that it has drifted.

The gate is optional, and unset means exactly today's behaviour. That keeps local development and all 113 existing admin test call sites working untouched, and means shipping the image before configuring the proxy cannot take the admin panel down. What it does not do is stay silent about it: the server warns at boot when the gate is inactive, naming what is unprotected. This project has been bitten repeatedly by controls that report success while doing nothing, and an unconfigured gate should be a visible choice rather than an invisible one.

An empty value is treated as unset rather than as a secret, because enforcing an empty secret would admit any caller sending an empty header. Comparison is timing-safe over SHA-256 digests of both sides: timingSafeEqual throws on buffers of unequal length, so comparing raw values would turn a short header into a 500 rather than a 403, and a length check first would leak the secret's length.

Turning it on requires the secret in two places at once — the stack environment and a proxy_set_header line on the gated location in NPM. Setting only one gives 403s until the other catches up. That coupling, and the three consequences above, are now written into the README beside the deployment section, since none of it is visible from the code.

Verified over real HTTP as well as in tests. Booting without the secret logs the warning and serves admin normally; booting with it returns 403 for a missing header, 403 for a wrong one, 200 for the right one, and leaves the public storefront at 200 throughout, with each refusal logged distinguishably and without echoing the value it was sent. 8 new unit tests, 9 new integration tests covering every admin router separately — a correct middleware nobody mounted would pass the unit tests and protect nothing. 106 unit and 153 integration passing, lint 0 errors and 8 warnings unchanged.

Refs #63
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:42:20 -05:00

82 lines
3.6 KiB
TypeScript
Executable File

import cron from 'node-cron';
import app from './app';
import { pool } from './db';
import { sendMail } from './mailer';
// Release cart holds whose expiry has passed.
async function sweepExpiredCarts(): Promise<void> {
try {
const { rows } = await pool.query(
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
);
for (const row of rows) {
await pool.query(`UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, [row.item_id]);
}
} catch (err) {
console.error('cart expiry sweep failed:', (err as Error).message);
}
}
// Remind customers who opted into marketing email about items still held in
// their cart.
async function sendCartReminders(): Promise<void> {
try {
const { rows } = await pool.query(`
SELECT c.email, c.name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN customers c ON c.id = ca.customer_id
JOIN items i ON i.id = ci.item_id
WHERE c.marketing_consent = true
AND (ci.last_reminder_sent_at IS NULL OR ci.last_reminder_sent_at < now() - interval '20 hours')
AND ci.expires_at > now()
`);
const byEmail = new Map<string, { name: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
for (const row of rows) {
if (!byEmail.has(row.email)) byEmail.set(row.email, { name: row.name, items: [] });
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
}
for (const [email, data] of byEmail) {
const itemList = data.items.map(i => `<li>${i.name} — reserved until ${i.expiresAt.toLocaleString()}</li>`).join('');
await sendMail(
email,
'Items waiting in your cart',
`<p>Hi${data.name ? ' ' + data.name : ''},</p>
<p>You still have items in your cart at Redefined Designs:</p>
<ul>${itemList}</ul>
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
);
const ids = data.items.map(i => i.cartItemId);
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
}
} catch (err) {
console.error('daily cart reminder job failed:', (err as Error).message);
}
}
// Neither scheduler has anything to await these with, so `void` states that the
// promise is deliberately dropped. That is only safe because both functions
// catch their own errors above — an escaping rejection would be unhandled, and
// Node terminates the process on those by default, so a database blip during
// the sweep would take the container down with it.
setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000);
cron.schedule('0 9 * * *', () => void sendCartReminders());
const PORT = parseInt(process.env.PORT || '3000', 10);
// Said at boot rather than left to be discovered. Without the secret the admin
// API is protected only by the reverse proxy's auth_request regex, which lives
// outside this repository and is bypassed entirely by anything reaching this
// container's published port directly. That is a defensible way to run — it is
// how this app has always run — but it should be a visible choice rather than a
// silent one. See middleware/adminGate.ts and #63.
if (!process.env.ADMIN_GATE_SECRET) {
console.warn(
'[admin-gate] ADMIN_GATE_SECRET is not set — /api/admin is protected only by the reverse ' +
'proxy. Anything able to reach this container directly can administer the store.'
);
}
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));