From 840a5dbcb3a8d236659eaafebecf8d11292d5b29 Mon Sep 17 00:00:00 2001 From: "khalid@traclabs.com" Date: Tue, 1 Sep 2026 23:29:16 -0500 Subject: [PATCH] v0.8: auth integration, design persistence (#23), protected sweep (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 134 ++++++++++++++ eslint.config.js | 4 +- scripts/sweep-worker.mjs | 113 ++++++++++++ server.js | 368 +++++++++++++++++++++++++++++++++++++-- src/main.jsx | 22 ++- 5 files changed, 614 insertions(+), 27 deletions(-) create mode 100644 scripts/sweep-worker.mjs diff --git a/.env.example b/.env.example index 4c6c0d2..7027095 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,140 @@ NODE_ENV=development # FILE_TTL_MS=86400000 # 24 hours # CLEANUP_INTERVAL_MS=3600000 # 1 hour +# ================================================================== +# auth-manager (v0.8) -- runtime +# ================================================================== +# +# auth-manager mounts customer magic-link + admin Google SSO + the +# account shell. If DATABASE_URL is NOT set, auth-manager is disabled +# gracefully and the rest of the host still boots -- useful when +# working on goods-editor / template-manager features without +# provisioning Postgres locally. + +# Postgres connection string. Required to enable auth-manager. +# Examples: +# Local Docker: postgres://postgres:postgres@localhost:5432/apparel +# Dokploy: postgres://:@:5432/ +# DATABASE_URL=postgres://postgres:postgres@localhost:5432/apparel + +# Fully-qualified base used to build: +# - the magic-link URL we email to customers (and print to console) +# - the Google OAuth redirect URI for admin SSO +# Must match the redirect URI registered in Google Cloud Console. +# In dev, set to http://localhost:3001. In prod, your storefront URL. +# Falls back to http://localhost:${PORT} if unset (dev only -- won't +# work behind a reverse proxy). +# PUBLIC_BASE_URL=http://localhost:3001 + +# Comma-separated allowlist of Google accounts that can sign in via +# admin SSO. Two or more entries strongly recommended for break-glass +# recovery (locked decision #4 in v08_Auth_v1.md). +# ADMIN_EMAILS=you@gmail.com,backup@gmail.com + +# Google OAuth 2.0 Web client credentials. Required for admin SSO. +# Create in Google Cloud Console -> APIs & Services -> Credentials -> +# Create credentials -> OAuth client ID -> Web application. Authorized +# redirect URI must be ${PUBLIC_BASE_URL}/auth/admin/google/callback. +# GOOGLE_CLIENT_ID=...apps.googleusercontent.com +# GOOGLE_CLIENT_SECRET=... + +# Optional: SMTP relay for magic-link emails. If unset, magic links +# ONLY print to the server's stdout (still fully functional for dev +# -- just copy/paste the link from your terminal). +# +# Recommended dev setup: Mailpit (https://mailpit.axllent.org). +# docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit +# then visit http://localhost:8025 to read sent mail. +# +# Resend integration is deferred to v2 -- see v08_Auth_v1.md +# "Email delivery in v1" section for rationale. +# SMTP_HOST=localhost +# SMTP_PORT=1025 +# SMTP_USER= +# SMTP_PASS= +# SMTP_FROM=no-reply@localhost + +# Optional: cookie domain for cross-subdomain session sharing +# (e.g. '.yourdomain.com' so checkout.yourdomain.com sees the same +# session as yourdomain.com). Usually leave unset -- SureCart's WP +# sidecar uses its own customer identity (passed via metadata at +# checkout creation), not auth-manager's session. +# COOKIE_DOMAIN= + +# ================================================================== +# Personalization facet (Project doc §R10) -- runtime +# ================================================================== +# +# Generic "customers filter the shop by a facet" feature. For THIS +# deployment the facet is "Pet Type" (dog/cat/reptile/…); other shops +# configure a different facet, or none. The facet VALUES are managed by +# an admin inside template-manager; only the DEFINITION is set here. +# +# FACET_KEY is the master switch. Leave it UNSET to run a plain, +# unfiltered shop: no sign-in prompt, no template filtering, the editor +# shows all stickers, and the account/admin facet UIs stay hidden. + +# The facet's stable key. Setting it turns the feature ON. Becomes the +# facet_key on customer_facet_values rows + the audienceTags scope. +# FACET_KEY=pet_type + +# Display label shown in the UI ("Choose your {LABEL}", account heading, +# admin section title). +# FACET_LABEL=Pet Type + +# Selection mode: `multi` (default) lets a customer pick several values +# (e.g. dog + cat); `single` restricts to one. +# FACET_SELECTION_MODE=multi + +# Require an admin to categorize every template before saving it +# (>=1 facet value, or marked General/universal). Default false. Turn +# this ON whenever the shop filters strictly, so an untagged template +# is never hidden silently. THIS shop: true. +# REQUIRE_TEMPLATE_CATEGORY=true + +# Require a signed-in customer to choose at least one value before +# browsing. Default false. THIS shop: false (the prompt nudges but is +# skippable; choosing nothing shows everything). +# REQUIRE_FACET_SELECTION=false + +# ================================================================== +# Image upscale service (goods-editor, 2026-06-10) -- runtime +# ================================================================== +# +# Optional REST service that upscales low-resolution customer photos +# before they hit the print render (goods-editor's /api/export +# pipeline). goods-editor reads these variables itself +# (imageUpscalerFromEnv inside mountEditorApi) -- no server.js wiring +# needed; setting UPSCALE_SERVICE_URL is the master switch. Unset = +# feature fully dormant, renders byte-identical to before. +# +# The editor independently FLAGS low-res images to customers/admins +# in the UI regardless of this setting -- the service only improves +# what actually prints. Upscaling is best-effort: if the service is +# down or slow, the render proceeds with the original pixels. +# +# Expected contract (adapt goods-editor's src/server/lib/ +# imageUpscaler.js if your service differs -- it's the single point +# of adaptation): +# POST $UPSCALE_SERVICE_URL with JSON +# { "image": "", "mime": "image/png", "scale": 2 } +# -> 200 with an image/* binary body, OR JSON { "image": "" } + +# Master switch. e.g. a Real-ESRGAN container in the same Dokploy +# project: http://upscaler:5000/upscale +# UPSCALE_SERVICE_URL= + +# Optional credential. Sent as 'Authorization: Bearer ' unless +# UPSCALE_SERVICE_AUTH_HEADER names a custom header (e.g. X-Api-Key), +# in which case the bare key is sent in that header. +# UPSCALE_SERVICE_API_KEY= +# UPSCALE_SERVICE_AUTH_HEADER=Authorization + +# Per-request abort + the service's integer scale ceiling. +# UPSCALE_SERVICE_TIMEOUT_MS=30000 +# UPSCALE_SERVICE_MAX_SCALE=4 + + # ────────────────────────────────────────────────────────────────── # Build-time — values the `docker compose build` step uses # ────────────────────────────────────────────────────────────────── diff --git a/eslint.config.js b/eslint.config.js index a3b5b73..de9c6f9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -91,7 +91,9 @@ export default defineConfig([ files: ['server.js'], extends: [js.configs.recommended], languageOptions: { - ecmaVersion: 2020, + // 2022 — server.js uses top-level await (auth-manager bootstrap, + // designs migration); 2020 made eslint fail at the parse step. + ecmaVersion: 2022, globals: globals.node, sourceType: 'module', }, diff --git a/scripts/sweep-worker.mjs b/scripts/sweep-worker.mjs new file mode 100644 index 0000000..33ed2e4 --- /dev/null +++ b/scripts/sweep-worker.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Sweep worker. Runs runSweep on a tick — trash hard-delete, asset + * orphans, audit log rotation, marketing preview queue drain. + * + * Usage: + * node scripts/sweep-worker.mjs # one-shot + * node scripts/sweep-worker.mjs --loop # run on interval + * + * Production: run as a separate process (PM2, systemd, etc.) so the + * main API server doesn't lose responsiveness when sweep does heavy + * work (especially preview rendering, which can take seconds per + * template at scale). + * + * For v1 the sweep interval is 60 minutes when run with --loop. + * Adjust via the --interval= flag. + */ + +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + runSweep, + marketingPreviewRenderer, + createTemplateIndex, +} from 'template-manager/server'; +import { PRODUCT_TYPES } from '../src/constants/products.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const STORAGE_PATH = join(__dirname, '..', 'templates'); + +const args = process.argv.slice(2); +const LOOP = args.includes('--loop'); +const intervalArg = args.find((a) => a.startsWith('--interval=')); +const INTERVAL_MIN = intervalArg ? parseInt(intervalArg.split('=')[1], 10) : 60; + +async function buildRenderer() { + try { + const { renderDesignComposite } = await import('goods-editor/server'); + const canvasMod = await import('canvas'); + const sharpMod = await import('sharp').catch(() => null); + + const factory = marketingPreviewRenderer({ + editorRenderer: async (args) => renderDesignComposite({ + ...args, + canvas: canvasMod.default || canvasMod, + sharp: sharpMod ? (sharpMod.default || sharpMod) : null, + mockupsDir: join(__dirname, '..', 'mockups'), + uploadsDir: join(__dirname, '..', 'uploads'), + stickersDir: join(__dirname, '..', 'public', 'stickers'), + storageDir: STORAGE_PATH, + }), + }); + + // The factory returns a renderer expecting context. We don't have + // the in-memory marketingPreviewQueue here — that lives in the + // running API server's process. The worker reconciles via the + // metadata.autoStaleSince field: any template with that field + // set is a regen candidate. + return async (templateId) => { + // Skip if no audit log handle in this script — that's mounted + // server-side. The renderer logs failures via the audit log + // when one is provided; without one, errors silently retry on + // the next sweep. + const { createAuditLog } = await import('template-manager/server/lib/auditLog.js').catch(() => ({})); + const auditLog = createAuditLog ? createAuditLog(STORAGE_PATH) : null; + return factory({ + templateId, + storagePath: STORAGE_PATH, + variantCatalog: PRODUCT_TYPES, + auditLog, + }); + }; + } catch (err) { + console.warn('⚠ Renderer not available; sweep will skip preview regen:', err.message); + return null; + } +} + +async function runOnce(renderPreview) { + const templateIndex = createTemplateIndex(STORAGE_PATH); + const summary = await runSweep({ + storagePath: STORAGE_PATH, + templateIndex, + renderPreview, + // marketingPreviewQueue is intentionally absent — the in-process + // queue lives in the API server. Worker process reconciles via + // metadata.autoStaleSince instead. + marketingPreviewQueue: null, + }); + console.log('Sweep:', JSON.stringify(summary, null, 2)); +} + +async function main() { + const renderPreview = await buildRenderer(); + if (!LOOP) { + await runOnce(renderPreview); + process.exit(0); + } + console.log(`▶ Sweep worker loop (interval: ${INTERVAL_MIN} min)`); + const tick = async () => { + try { await runOnce(renderPreview); } + catch (err) { console.error('Sweep tick failed:', err); } + }; + await tick(); + setInterval(tick, INTERVAL_MIN * 60 * 1000); +} + +main().catch((err) => { + console.error('Sweep worker failed:', err); + process.exit(1); +}); diff --git a/server.js b/server.js index 5df9733..e334779 100644 --- a/server.js +++ b/server.js @@ -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/ +// 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//sizes// +// 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/` +// (or `/uploads/preview/`). 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// +// metadata.json + versions/*.json, products//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: +// /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 //.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 `/_products_index.json` +// + `/products//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// + // sizes//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' }); diff --git a/src/main.jsx b/src/main.jsx index 9e82ab9..c551d12 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,16 +1,24 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { AuthProvider } from '@khalidadil/auth-manager' +import '@khalidadil/auth-manager/styles.css' import './index.css' -import App from './App' +import App from './AppRouter' -// Service worker registration is handled inside via the -// `useRegisterSW` hook from `virtual:pwa-register/react`. We removed the -// hand-rolled `navigator.serviceWorker.ready` listener that used to live here -// because (a) it didn't actually register the SW — it just listened for updates -// — and (b) it duplicated state the plugin already exposes via the hook. +// v0.8 — wraps the whole tree so useCustomer/useAdmin hooks +// share a single /api/auth/me fetch instead of each call site re-fetching. +// AuthProvider degrades gracefully if /api/auth/me returns 404 (host +// running without DATABASE_URL set), so this wrap is safe even when +// auth-manager is disabled server-side. +// +// SW registration lives inside via the `useRegisterSW` +// hook — we removed the hand-rolled `navigator.serviceWorker.ready` +// listener that used to live here. createRoot(document.getElementById('root')).render( - + + + , )