Loads Brevo's web tracker for a signed-in customer who has consented, reports route changes as page views, and tracks the three events the issue asked for: added_to_cart, favorited, and checkout_completed. The four design questions were settled on the issue in August and this implements those answers. The consent gate is the part worth reading. The decision recorded on the issue was "gate it behind consent", but the sentence customers actually agreed to named only email: "I want to receive occasional emails about new one-of-a-kind items". Gating a tracker on `marketing_consent` while that was the stored wording would have treated "email me about new items" as authorisation to send someone's browsing to a third party, which it does not say — and this project stores the wording verbatim against each customer precisely so that a record says what the customer saw. So the sentence is widened here, and the tracker is gated on `analytics_consent`, a field the server computes by comparing the wording stored against a customer with the current constant. Changing the sentence therefore does not retroactively widen anybody's consent: everyone who agreed to the old text keeps their email consent and is not tracked until they re-consent through the account page. A boolean alone could not tell those two populations apart, which is the whole reason the text is stored per customer. `analyticsConsent` is exported and has its own unit test, because "agreeing to the old wording does not authorise tracking" is the rule that silently tracks people if it regresses — their flag really is true. QA stays out of the live Brevo account by construction rather than by remembering. The key is per-environment, the tracker never loads without one, and `docker-compose.qa.yml` sets an empty literal with no stack variable behind it, so nothing can inherit a value from the host or be pasted in from production's stack. Same reasoning as QA_DB_PASSWORD and the QA_SMTP_ names beside it. Events are reported from the API layer rather than the UI call sites, so no caller can add to the cart or favorite an item without it being counted, and each fires only after the response was accepted — a refused add is not reported as one. The two checkout completions each name their processor, because a demo purchase charges nothing and counting it as a sale would overstate revenue. The privacy policy gains an analytics section in this change rather than a follow-up, since the published policy previously described none of this and would otherwise have lagged the code. It is deliberate about the limits: withdrawing consent stops further reporting, but anything already sent stays with Brevo, and a script already injected cannot be un-injected — `stopBrevoTracking` stops calls, it does not unload sa.js. That is said in the code too, because "tracking stops" reads as a stronger promise than any web tracker can make. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 474 unit tests passing across 33 suites, and the frontend production build green including the compose-environment guard. Not verified: integration and e2e, which need a database and a Node this machine does not have active, and no real Brevo key was exercised — the tracker has never been observed reporting to an actual account. Closes #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
6.5 KiB
TypeScript
Executable File
129 lines
6.5 KiB
TypeScript
Executable File
import express, { Request, Response, NextFunction } from 'express';
|
|
import cookieParser from 'cookie-parser';
|
|
import path from 'path';
|
|
import itemsRouter from './routes/items';
|
|
import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRouter } from './routes/cartCheckout';
|
|
import adminRouter from './routes/admin';
|
|
import adminCustomersRouter from './routes/adminCustomers';
|
|
import adminSettingsRouter from './routes/adminSettings';
|
|
import adminEmailTemplatesRouter from './routes/adminEmailTemplates';
|
|
import adminCategoriesRouter from './routes/adminCategories';
|
|
import adminTagsRouter from './routes/adminTags';
|
|
import adminUploadLinksRouter from './routes/adminUploadLinks';
|
|
import adminItemDraftsRouter from './routes/adminItemDrafts';
|
|
import intakeActionsRouter from './routes/intakeActions';
|
|
import intakeRouter from './routes/intake';
|
|
import adminVersionRouter from './routes/adminVersion';
|
|
import adminConfigRouter from './routes/adminConfig';
|
|
import filtersRouter from './routes/filters';
|
|
import customersRouter from './routes/customers';
|
|
import publicRouter from './routes/public';
|
|
import cartRouter from './routes/cart';
|
|
import shippingAddressesRouter from './routes/shippingAddresses';
|
|
import clientErrorsRouter from './routes/clientErrors';
|
|
import { attachCustomer } from './middleware/customerAuth';
|
|
import { requireAdminGate } from './middleware/adminGate';
|
|
import { asyncRoute } from './asyncRoute';
|
|
import { uploadsRouter } from './uploads';
|
|
import { trimTrailingSlashes } from './utils';
|
|
|
|
const app = express();
|
|
// Express advertises itself in X-Powered-By by default, which hands an
|
|
// attacker the server stack for free when picking exploits to try.
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', 1);
|
|
|
|
app.use('/webhooks/paypal', express.json(), cartCheckoutWebhookRouter);
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
// Wrapped like any route: attachCustomer awaits a session lookup, and mounted
|
|
// globally an unforwarded rejection here would hang every request in the app —
|
|
// including the routes that wrap their own handlers correctly.
|
|
app.use(asyncRoute(attachCustomer));
|
|
app.use('/uploads', uploadsRouter(process.env.UPLOADS_DIR || '/app/uploads'));
|
|
|
|
app.get('/api/config', (_req, res) => {
|
|
const clientId = process.env.PAYPAL_CLIENT_ID;
|
|
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
|
|
res.json({
|
|
paypalClientId: isPlaceholder ? null : clientId,
|
|
demoMode: process.env.DEMO_MODE !== 'false',
|
|
currency: process.env.SITE_CURRENCY || 'USD',
|
|
// Where uploaded images should be fetched from (#103). Empty means the
|
|
// app's own origin, which is both the default and what local development
|
|
// has — there is no second hostname on a laptop. Set it to a hostname of
|
|
// its own in production and user-supplied files stop sharing an origin with
|
|
// the application, which is the whole unit of trust in a browser.
|
|
//
|
|
// Sent at runtime rather than built in, so one image serves every
|
|
// environment, the same reason paypalClientId and demoMode are here.
|
|
//
|
|
// Trailing slash trimmed so callers can join with a stored path, which
|
|
// always begins with one, without producing a double.
|
|
uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? ''),
|
|
// Brevo's Marketing Automation key (#56). Not a secret — it ships to the
|
|
// browser by design — but it differs per environment, which is the whole
|
|
// reason it is here rather than built in.
|
|
//
|
|
// Null when unset, and the tracker never loads without it. That is what
|
|
// keeps QA out of production's Brevo account: QA sets no key, so no QA
|
|
// browsing is ever reported, and there is no flag anyone can forget to
|
|
// turn off. Same shape as paypalClientId above.
|
|
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null
|
|
});
|
|
});
|
|
|
|
app.use('/api/items', itemsRouter);
|
|
app.use('/api/filters', filtersRouter);
|
|
app.use('/api/cart', cartRouter);
|
|
// Public and unauthenticated by design (#222). No requireAdminGate: the token
|
|
// in the path is the whole access control, and every refusal is a 404.
|
|
app.use('/api/intake', intakeRouter);
|
|
app.use('/api/intake-actions', intakeActionsRouter);
|
|
app.use('/api/checkout/cart', cartCheckoutRouter);
|
|
// requireAdminGate is attached to each admin router rather than to a path
|
|
// prefix. Attached to the router, an admin router added later at some other
|
|
// path still inherits it — and since the proxy only injects the header on the
|
|
// paths its regex matches, that router refuses loudly on its first request
|
|
// instead of being quietly public. See middleware/adminGate.ts and #63.
|
|
app.use('/api/admin/customers', requireAdminGate, adminCustomersRouter);
|
|
app.use('/api/admin/settings', requireAdminGate, adminSettingsRouter);
|
|
app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRouter);
|
|
app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter);
|
|
app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
|
|
app.use('/api/admin/upload-links', requireAdminGate, adminUploadLinksRouter);
|
|
app.use('/api/admin/item-drafts', requireAdminGate, adminItemDraftsRouter);
|
|
app.use('/api/admin/version', requireAdminGate, adminVersionRouter);
|
|
app.use('/api/admin/config', requireAdminGate, adminConfigRouter);
|
|
app.use('/api/admin', requireAdminGate, adminRouter);
|
|
app.use('/api/customers/me/addresses', shippingAddressesRouter);
|
|
app.use('/api/customers', customersRouter);
|
|
app.use('/api/client-errors', clientErrorsRouter);
|
|
app.use('/', publicRouter);
|
|
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
const staticDir = path.join(__dirname, '..', 'public');
|
|
app.use(express.static(staticDir));
|
|
app.get('*', (_req, res) => {
|
|
res.sendFile(path.join(staticDir, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// Mounted last, so it sees errors from every route above. Without it, a route
|
|
// that hands an error to next() falls through to Express's default handler,
|
|
// and — worse — an async route that rejects never responds at all, leaving the
|
|
// client hanging. A hung request is indistinguishable from an empty catalogue
|
|
// in the UI, which is exactly how a schema mismatch once read as "the store
|
|
// has no items". Always answer.
|
|
//
|
|
// The four-argument signature is what marks this as error middleware; `next`
|
|
// is unused but must stay for Express to recognise it.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
|
console.error(err);
|
|
if (res.headersSent) return;
|
|
res.status(500).json({ error: 'internal error' });
|
|
});
|
|
|
|
export default app;
|