v0.8: auth integration, design persistence (#23), protected sweep (#24)

Server (spans the v0.6→v0.8 arc in one file):
- template-manager mounted with the productsStore catalog, PNG-only
  mockup multer, facet personalization config (§R10, FACET_KEY-gated,
  inert when unset), and the marketing-preview drainer (2s tick, one
  render per tick, .unref()).
- auth-manager bootstrap: DATABASE_URL gates the pg Pool, migrations,
  and mountAuth (magic-link customer + Google SSO admin + facet);
  absent DATABASE_URL logs a warning and the host boots without auth.
- goods-editor design persistence (#23): idempotent 001_designs.sql
  applied at boot, db handle passed to mountEditorApi so /api/designs
  routes mount; without Postgres the client falls back to
  cart-without-design_id.
- reference-protected TTL sweep (#24): before each pass, every
  /uploads/ basename referenced by template-manager JSON or persisted
  designs is collected and protected, and exports/designs (db-driven
  lifecycle, unrecoverable sources) is skipped entirely. Sweep kickoff
  moves below the bootstrap (pgPool TDZ). sweep-worker.mjs is the
  standalone process variant for production.

Client: main.jsx wraps the tree in AuthProvider (single /api/auth/me
fetch shared by useCustomer/useAdmin, degrades on 404) and mounts
AppRouter. eslint bumps server.js to ecmaVersion 2022 (top-level
await); .env.example documents the full v0.8 env surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
khalid@traclabs.com
2026-09-01 23:29:16 -05:00
parent dfa1f90a16
commit 840a5dbcb3
5 changed files with 614 additions and 27 deletions

368
server.js
View File

@@ -16,7 +16,16 @@
// • POST /api/export — the canvas+sharp render pipeline
// • GET /api/download/:filename — serve exported PNGs
// • GET /api/health — health probe
// • Font registration
// • Font registration with node-canvas, plus GET
// /api/fonts/fonts.css and GET /api/fonts/<filename>
// for browser-side delivery. index.html loads the .css
// directly; the Google Fonts CDN link the host used to
// ship is gone with v0.8.
// • POST/GET/DELETE /api/designs/* — design persistence,
// mounted only when a Postgres `db` handle is passed to
// mountEditorApi. Wired (#23): we pass pgPool when
// DATABASE_URL is set; without it the routes don't mount
// and the client falls back to cart-without-design_id.
import express from 'express';
import cors from 'cors';
@@ -28,10 +37,17 @@ import rateLimit from 'express-rate-limit';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { fileURLToPath } from 'url';
import { dirname, join, resolve, sep } from 'path';
import { mkdirSync, existsSync, readdirSync, statSync, rmSync } from 'fs';
import { basename, dirname, join, resolve } from 'path';
import { mkdirSync, existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
import { mountEditorApi } from 'goods-editor/server';
import { renderDesignComposite } from 'goods-editor/server';
import { mountTemplateStore } from 'template-manager/server';
import { marketingPreviewRenderer } from 'template-manager/server';
import { mountAuth, runMigrations } from '@khalidadil/auth-manager/server';
import pkg from 'pg';
const { Pool } = pkg;
import { SEED_PRODUCTS } from './src/constants/seedProducts.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -53,13 +69,19 @@ app.use(pinoHttp({
// ── Filesystem layout ──────────────────────────────────────────────
const uploadsDir = join(__dirname, 'uploads');
const exportsDir = join(__dirname, 'exports');
const templatesStorageDir = join(__dirname, 'templates');
const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts');
const stickersDir = IS_PRODUCTION
? join(__dirname, 'dist', 'stickers')
: join(__dirname, 'public', 'stickers');
const uploadsRoot = resolve(uploadsDir);
// (uploadsRoot + `sep` removed — dead since the path-guarded routes
// moved into goods-editor/template-manager; eslint now parses this
// file (ecmaVersion 2022) and flagged them.)
[uploadsDir, exportsDir].forEach((dir) => {
// v0.6 — mockupsDir is gone (variant mockups removed). Product
// mockups live under templatesStorageDir/products/<id>/sizes/<sid>/
// and are managed entirely by template-manager.
[uploadsDir, exportsDir, templatesStorageDir].forEach((dir) => {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
});
@@ -67,36 +89,121 @@ const uploadsRoot = resolve(uploadsDir);
const FILE_TTL_MS = parseInt(process.env.FILE_TTL_MS || '', 10) || 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '', 10) || 60 * 60 * 1000;
function cleanupOldFiles(dir, ttlMs) {
if (!existsSync(dir)) return 0;
// Referenced-file exemption (#24) — template JSONs (element srcs,
// slot previewSrc, entry-config imagery) and persisted designs
// (Postgres designs.design_json) reference files as `/uploads/<name>`
// (or `/uploads/preview/<name>`). Those references are long-lived;
// sweeping the files at 24h breaks template renders and design
// re-renders. Before each sweep pass we collect every referenced
// basename (uploads filenames are UUID-based, so basename matching
// is collision-free) and skip deleting those files. A simple regex
// over raw JSON text keeps this robust to schema drift.
const UPLOADS_REF_RE = /\/uploads\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)/g;
function collectUploadRefs(text, out) {
UPLOADS_REF_RE.lastIndex = 0;
let m;
while ((m = UPLOADS_REF_RE.exec(text)) !== null) {
out.add(basename(m[1]));
}
}
// Recursively scan a directory's *.json files for /uploads/ references.
// Covers template-manager's whole storage tree: templates/<id>/
// metadata.json + versions/*.json, products/<id>/product.json, and
// the _*.json index docs.
function scanDirForUploadRefs(dir, out) {
if (!existsSync(dir)) return;
let entries = [];
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = join(dir, entry.name);
try {
if (entry.isDirectory()) {
scanDirForUploadRefs(full, out);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
collectUploadRefs(readFileSync(full, 'utf8'), out);
}
} catch { /* unreadable entry — skip */ }
}
}
// Built fresh once per sweep pass (templates/designs change between
// passes; a single Set per pass is cache enough).
async function collectReferencedUploads() {
const referenced = new Set();
try { scanDirForUploadRefs(templatesStorageDir, referenced); } catch { /* never block the sweep */ }
// Persisted designs — only when Postgres is wired. Any db error
// (table not migrated yet, connection down) skips silently: the
// sweep must never crash the host.
if (pgPool) {
try {
const { rows } = await pgPool.query('SELECT design_json FROM designs WHERE deleted_at IS NULL');
for (const row of rows) {
collectUploadRefs(JSON.stringify(row.design_json), referenced);
}
} catch { /* db unavailable — protect what templates reference */ }
}
return referenced;
}
function cleanupOldFiles(dir, ttlMs, protectedNames = null, skipDirs = null) {
if (!existsSync(dir)) return { removed: 0, protectedCount: 0 };
const now = Date.now();
let removed = 0;
let protectedCount = 0;
let entries = [];
try { entries = readdirSync(dir); } catch { return 0; }
try { entries = readdirSync(dir); } catch { return { removed, protectedCount }; }
for (const entry of entries) {
const full = join(dir, entry);
try {
const stat = statSync(full);
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
rmSync(full);
removed++;
if (protectedNames && protectedNames.has(entry)) {
protectedCount++;
} else {
rmSync(full);
removed++;
}
} else if (stat.isDirectory()) {
removed += cleanupOldFiles(full, ttlMs);
if (skipDirs && skipDirs.has(resolve(full))) continue;
const sub = cleanupOldFiles(full, ttlMs, protectedNames, skipDirs);
removed += sub.removed;
protectedCount += sub.protectedCount;
}
} catch { /* skip */ }
}
return removed;
return { removed, protectedCount };
}
function runCleanup() {
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS);
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS);
if (u > 0 || e > 0) {
logger.info({ removedUploads: u, removedExports: e, ttlMs: FILE_TTL_MS }, 'Cleanup pass complete');
// exports/designs is goods-editor's designsDir (mountEditorApi default:
// <exportsDir>/designs). It holds persisted design artifacts — source
// images, thumbnails, print renders — whose lifecycle is db-driven
// (designs.deleted_at soft-delete, 30-day guest TTL), not this sweep's
// 24h TTL. Source images in particular are unrecoverable, so the sweep
// skips the whole subtree.
const designsArtifactsDir = resolve(join(exportsDir, 'designs'));
async function runCleanup() {
try {
const referenced = await collectReferencedUploads();
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS, referenced);
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS, null, new Set([designsArtifactsDir]));
if (u.removed > 0 || e.removed > 0 || u.protectedCount > 0) {
logger.info({
removedUploads: u.removed,
removedExports: e.removed,
protectedUploads: u.protectedCount,
ttlMs: FILE_TTL_MS,
}, 'Cleanup pass complete');
}
} catch (err) {
logger.warn({ err: err.message }, 'Cleanup pass failed; will retry next interval');
}
}
runCleanup();
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
// NOTE: the initial run + interval start AFTER the auth-manager
// bootstrap below — collectReferencedUploads reads `pgPool`, which is
// declared there (TDZ until then).
// ── App-level middleware (CORS, JSON, rate limits) ─────────────────
const corsOptions = IS_PRODUCTION
@@ -106,6 +213,106 @@ app.use(cors(corsOptions));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// ── Facet personalization config (Project doc §R10) ──────────────
// Env-driven, host-injected into both module mounts. FACET_KEY is the
// master switch — unset ⇒ the feature is inert (facet-config reports
// disabled, the account routes no-op, templates are unfiltered, and the
// editor shows all stickers). The facet *values* (dog/cat/…) are
// admin-managed inside template-manager; only the definition is here.
const envBool = (v, dflt = false) => (
v === undefined || v === null || v === ''
? dflt
: /^(1|true|yes|on)$/i.test(String(v).trim())
);
const facetConfig = {
key: (process.env.FACET_KEY || '').trim() || null,
label: (process.env.FACET_LABEL || '').trim() || null,
selectionMode: process.env.FACET_SELECTION_MODE === 'single' ? 'single' : 'multi',
requireSelection: envBool(process.env.REQUIRE_FACET_SELECTION, false),
};
const requireTemplateCategory = envBool(process.env.REQUIRE_TEMPLATE_CATEGORY, false);
if (facetConfig.key) {
logger.info({
key: facetConfig.key,
label: facetConfig.label,
selectionMode: facetConfig.selectionMode,
requireSelection: facetConfig.requireSelection,
requireTemplateCategory,
}, 'Personalization facet enabled');
}
// ── auth-manager bootstrap ─────────────────────────────────────────
// Runs migrations + mounts auth routes if DATABASE_URL is set. If
// it isn't, we log a warning and continue without auth so a developer
// can still iterate on goods-editor / template-manager pieces without
// provisioning Postgres locally.
let pgPool = null;
if (process.env.DATABASE_URL) {
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await runMigrations(pgPool, { logger });
mountAuth(app, {
db: pgPool,
apiBase: '/api',
callbackBaseUrl: process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`,
smtp: process.env.SMTP_HOST ? {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM,
} : undefined,
fromAddress: process.env.SMTP_FROM || 'no-reply@example.com',
appName: 'Pawfectly Yours',
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
adminEmails: (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim()).filter(Boolean),
cookieDomain: process.env.COOKIE_DOMAIN || undefined,
isProduction: IS_PRODUCTION,
// Facet personalization (Project doc §R10) — scopes the
// customer_facet_values rows + adds facetValues to /me. Inert
// when facetConfig.key is null.
facet: facetConfig,
logger,
});
} catch (err) {
logger.error({ err: err.message }, 'auth-manager bootstrap failed; auth disabled');
try { await pgPool.end(); } catch { /* ignore */ }
pgPool = null;
}
} else {
logger.warn('DATABASE_URL not set — auth-manager disabled. Set DATABASE_URL in .env to enable customer accounts and admin SSO.');
}
// ── goods-editor designs migration (#23) ──────────────────────────
// The module documents that the HOST applies its designs schema before
// passing `db` to mountEditorApi (001_designs.sql is idempotent —
// CREATE TABLE IF NOT EXISTS + CREATE OR REPLACE). With the file: dep
// the SQL is reachable through node_modules; a packed install ships
// dist/ only, so a missing file logs a warning instead of crashing —
// the /designs routes still mount and fail loudly until the table is
// created by hand.
if (pgPool) {
const designsMigrationPath = join(
__dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations', '001_designs.sql',
);
try {
if (existsSync(designsMigrationPath)) {
await pgPool.query(readFileSync(designsMigrationPath, 'utf8'));
logger.info('goods-editor designs migration applied');
} else {
logger.warn({ path: designsMigrationPath }, 'goods-editor designs migration not found — apply 001_designs.sql manually before using design persistence');
}
} catch (err) {
logger.error({ err: err.message }, 'goods-editor designs migration failed — POST /api/designs will error until the designs table exists');
}
}
// TTL sweep kickoff (#24) — here rather than at the definition site
// because collectReferencedUploads reads pgPool.
runCleanup();
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
@@ -149,6 +356,26 @@ const upload = multer({
limits: { fileSize: 20 * 1024 * 1024 },
});
// Separate multer instance for variant mockup uploads. The template-
// manager's variants route calls `.single('file')` on this and then
// renames `req.file.path` to <mockupsPath>/<productType>/<variant>.png
// inside the route handler. We use disk storage with a temp filename
// in the host's uploadsDir; the rename moves it to its final mockups
// location. Restricted to PNG since that's the file extension the
// route writes — a JPEG/WebP source would end up with a misleading
// .png extension on disk.
const mockupUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, _file, cb) => cb(null, `mockup-${uuidv4()}.tmp`),
}),
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'image/png') cb(null, true);
else cb(new Error('Only PNG mockups are accepted.'));
},
limits: { fileSize: 10 * 1024 * 1024 },
});
app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res) => {
try {
if (!req.file) {
@@ -184,6 +411,11 @@ app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res)
});
// ── Module-owned: editor API (export, download, health) ────────────
// v0.8 (#23) — `db` gates the design-persistence routes (POST/GET/
// DELETE /api/designs…). pgPool is null without DATABASE_URL (or when
// the auth bootstrap failed), in which case the routes simply don't
// mount and the client's persistCurrentDesign 404s into its graceful
// cart-without-design_id fallback.
mountEditorApi(app, {
canvas,
sharp,
@@ -193,8 +425,106 @@ mountEditorApi(app, {
uploadsDir,
basePath: '/api',
logger,
db: pgPool,
});
// Template store (admin + public). adminAuth pass-through for v1
// per the plan; v1.5 swaps in a real middleware via server/lib/auth.js.
//
// v0.6 — the host no longer passes a variantCatalog or phoneModels.
// template-manager owns the catalog (productsStore) and accepts a
// seedProducts array used on first boot only. After the first boot,
// the catalog lives in `<templatesStorageDir>/_products_index.json`
// + `<templatesStorageDir>/products/<id>/product.json` and the seed
// is ignored even if it changes (intentional: prevents unintended
// catalog rewrites on server restart).
const templateStore = mountTemplateStore(app, {
storagePath: templatesStorageDir,
uploadsPath: uploadsDir,
adminAuth: (req, res, next) => next(),
seedProducts: SEED_PRODUCTS,
multer: mockupUpload,
logger,
basePath: '/api',
// Facet personalization (Project doc §R10) — server-enforced template
// categorization gate + the facet definition exposed via
// GET /api/public/facet-config. Both inert when facetConfig.key null.
requireTemplateCategory,
facet: facetConfig,
onTemplateChange: (templateId, kind) => {
logger.info({ templateId, kind }, 'Template changed');
// TODO (v0.4): trigger SSG rebuild for the affected template's
// shop page. v1 no-op; client re-fetches anyway.
},
});
// ── Marketing preview drainer ──────────────────────────────
//
// Periodically pulls templateIds off the marketing-preview queue and
// renders preview.png for each. The queue is in-memory and populated
// by (a) version creates, (b) variant mockup uploads, and (c) explicit
// regenerate clicks from the admin UI.
//
// Without this drainer, those enqueues accumulate silently — the
// server sets `autoStaleSince` on the metadata but nothing ever
// clears it, so the UI's "regenerating…" badge would stick forever.
//
// Implementation notes
// ────────────────────
// • The renderer factory takes goods-editor's `renderDesignComposite`
// as `editorRenderer`. We wrap it here to close over the host's
// `canvas` module + the mockups/uploads/storage dirs, since
// marketingPreviewRenderer calls editorRenderer with only the
// design data (no native deps).
// • Interval is 2s for snappy admin feedback. Render time on a
// typical 800×800 composite is well under a second on a modern
// laptop, so even a quick reload after "Save Version" should
// find a fresh preview waiting.
// • We drain ONE item per tick so a burst of enqueues doesn't pin
// the event loop. Multiple items just get processed over multiple
// ticks.
// • .unref() the timer so it doesn't keep the process alive at
// shutdown.
const renderOne = marketingPreviewRenderer({
editorRenderer: (designArgs) => renderDesignComposite({
...designArgs,
canvas,
sharp,
// v0.6 — mockups now live under templatesStorageDir/products/<id>/
// sizes/<sid>/mockup.png. renderDesignComposite picks the right
// path from the resolved variant's `mockupPath` field. We pass
// templatesStorageDir as a parent for path resolution.
mockupsDir: templatesStorageDir,
uploadsDir,
stickersDir,
storageDir: templatesStorageDir,
}),
});
const PREVIEW_DRAIN_INTERVAL_MS = parseInt(
process.env.PREVIEW_DRAIN_INTERVAL_MS || '',
10,
) || 2000;
async function drainPreviewQueue() {
const id = templateStore.marketingPreviewQueue.dequeue();
if (!id) return;
try {
await renderOne({
templateId: id,
storagePath: templatesStorageDir,
// v0.6 — productsStore replaces variantCatalog for variant lookup.
productsStore: templateStore.productsStore,
auditLog: templateStore.auditLog,
});
logger.info({ templateId: id }, 'Marketing preview rendered');
} catch (err) {
logger.error({ templateId: id, err: err.message }, 'Marketing preview render failed');
}
}
setInterval(drainPreviewQueue, PREVIEW_DRAIN_INTERVAL_MS).unref();
// ── 404 + SPA catch-alls ───────────────────────────────────────────
app.all('/api/*', (_req, res) => {
res.status(404).json({ error: 'API route not found' });