Compare commits

...

2 Commits

Author SHA1 Message Date
khalid@traclabs.com
5c21188df1 server: thread auth into mountEditorApi + apply ALL goods-editor migrations
Closes the coverage-gap audit's sharpest item (H8/D-auth): mountAuth's
return is captured (authGuards) and design-route ownership is passed
explicitly to mountEditorApi (customerId/isAdmin off req.customer/
req.admin — matches the module defaults but survives default drift);
/api/fonts/registered is admin-gated by a host-level requireAdmin
registered ahead of the module route (auth.middleware can't do this:
the module applies it to the whole /designs group, which would lock
guests out of design persistence). mockupsDir/storageDir now threaded
so design-route thumbnails get the mockup backdrop. Dev harness
unchanged: without DATABASE_URL the roster stays open and ownership
resolves to the guest model.

Also fixes a real bug the boot test surfaced: the host hardcoded
001_designs.sql, silently skipping 002_design_family.sql — the store
SELECTs design_family_id unconditionally, so every GET /api/designs
500'd ('column does not exist'). Now applies every *.sql in the
module's migrations dir in filename order (all idempotent).

Verified by booting both modes: auth ON → roster 403 admin_required,
designs unknown-id 404 (was 500), both migrations applied on a fresh
db; auth OFF → roster 200 with the font list, warning logged. NOTE:
goods-editor's ./server export resolves to dist/, which was stale
(built Jul 7 — predating the module's auth option, roster route, and
Aug security fixes); rebuilt from the branch source. Any deploy must
run the module's build, not just npm install the file: dep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 00:20:15 -05:00
khalid@traclabs.com
26f30ce388 admin.css: style the v0.8 inventory admin surfaces
Closes the audit's unstyled-classes item: 48 emitted-but-unstyled
classes across InventoryListPanel (tm-admin-inventory__*, mirroring the
tm-admin-list chrome; stock badge ok/low/out on the success/warning/
danger tokens), InventoryDetailPanel (tm-inventory-detail__* — stock
hero with state-colored count, tx history table with signed deltas,
danger zone) and BulkReceiveModal (tm-bulk-receive__* + the wide modal
card). Class-inventory header updated per the file's own convention;
verified by diffing emitted class names against stylesheet selectors
(only template-literal modifier stems and the tm-meta__field-riding
tm-facet-tags wrapper remain, both intentional).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 00:20:15 -05:00
2 changed files with 456 additions and 15 deletions

View File

@@ -247,11 +247,14 @@ if (facetConfig.key) {
// can still iterate on goods-editor / template-manager pieces without
// provisioning Postgres locally.
let pgPool = null;
// mountAuth's returned guards (requireAdmin etc.) — captured so they can
// be threaded into other module mounts below. Null when auth is disabled.
let authGuards = null;
if (process.env.DATABASE_URL) {
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await runMigrations(pgPool, { logger });
mountAuth(app, {
authGuards = mountAuth(app, {
db: pgPool,
apiBase: '/api',
callbackBaseUrl: process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`,
@@ -279,32 +282,39 @@ if (process.env.DATABASE_URL) {
logger.error({ err: err.message }, 'auth-manager bootstrap failed; auth disabled');
try { await pgPool.end(); } catch { /* ignore */ }
pgPool = null;
authGuards = 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) ──────────────────────────
// ── goods-editor designs migrations (#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.
// passing `db` to mountEditorApi. Every file in its migrations/ dir is
// idempotent (CREATE TABLE/ADD COLUMN IF NOT EXISTS), so we apply ALL
// of them in filename order — hardcoding 001 silently skipped
// 002_design_family.sql when the module grew it (the store SELECTs
// design_family_id unconditionally → every GET /api/designs 500'd).
// With the file: dep the SQL is reachable through node_modules; a
// packed install ships dist/ only, so a missing dir logs a warning
// instead of crashing — the /designs routes still mount and fail
// loudly until the schema is applied by hand.
if (pgPool) {
const designsMigrationPath = join(
__dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations', '001_designs.sql',
const designsMigrationsDir = join(
__dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations',
);
try {
if (existsSync(designsMigrationPath)) {
await pgPool.query(readFileSync(designsMigrationPath, 'utf8'));
logger.info('goods-editor designs migration applied');
if (existsSync(designsMigrationsDir)) {
const files = readdirSync(designsMigrationsDir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
await pgPool.query(readFileSync(join(designsMigrationsDir, file), 'utf8'));
}
logger.info({ files }, 'goods-editor designs migrations applied');
} else {
logger.warn({ path: designsMigrationPath }, 'goods-editor designs migration not found — apply 001_designs.sql manually before using design persistence');
logger.warn({ path: designsMigrationsDir }, 'goods-editor migrations dir not found — apply its *.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');
logger.error({ err: err.message }, 'goods-editor designs migrations failed — POST /api/designs will error until the schema is applied');
}
}
@@ -416,6 +426,18 @@ app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res)
// 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.
//
// Font-registration roster: ops-diagnosis data, admin-only when auth is
// live. Registered BEFORE mountEditorApi so this guard runs ahead of the
// module's own GET /api/fonts/registered handler (non-admins 403, admins
// fall through). We deliberately do NOT pass auth.middleware into
// mountEditorApi for this — the module applies that same middleware to
// the whole /designs group, and a requireAdmin there would lock guests
// out of design persistence (guest rows are a designed-in feature).
if (authGuards) {
app.use('/api/fonts/registered', authGuards.requireAdmin);
}
mountEditorApi(app, {
canvas,
sharp,
@@ -423,9 +445,25 @@ mountEditorApi(app, {
fontsDir,
stickersDir,
uploadsDir,
// Coverage-gap audit items: the design routes' thumbnail generator
// wants the mockup/template roots (previously only passed to
// renderDesignComposite for marketing previews, not here).
mockupsDir: templatesStorageDir,
storageDir: templatesStorageDir,
basePath: '/api',
logger,
db: pgPool,
// Design-route ownership (coverage-gap audit, sharpest item): owned
// rows 404 for non-owners, admins bypass. These match the module's
// documented defaults but are passed explicitly so the enforcement is
// visible here and survives any future change to those defaults.
// req.customer / req.admin are populated by auth-manager's
// authMiddleware, which mountAuth app.use()s ABOVE this mount; when
// auth is disabled both resolve null/false = the open dev harness.
auth: {
customerId: (req) => req.customer?.id ?? null,
isAdmin: (req) => !!req.admin,
},
});
// Template store (admin + public). adminAuth pass-through for v1

View File

@@ -37,6 +37,15 @@
* status/actions/upload-btn/remove
* tm-featured, tm-featured__list/row/handle/name/type
* tm-trash, tm-trash__header/desc/list/date
* tm-admin-inventory, tm-admin-inventory__header/title/header-actions/
* new/bulk/filters/search/product-filter/low-filter/error/empty/
* table/row/cell-link/option-pill/sku/stock-badge[--ok|--low|--out]/
* stock-label
* tm-inventory-detail, tm-inventory-detail__back/header/options/
* option-pill/stock/stock-count[--ok|--low|--out]/stock-meta/
* stock-state/stock-actions/meta/error/save/history/tx-table/
* delta-pos/delta-neg/danger/delete
* tm-bulk-receive__rows/row/variant/qty/remove/add, tm-modal__card--wide
*/
/* ── Design tokens ────────────────────────────────────────────────── */
@@ -2377,3 +2386,397 @@
font-size: 12px;
color: var(--as-text-subtle);
}
/* ── Inventory admin (`tm-admin-inventory`, v0.8 material kinds) ──
List page at /admin/inventory. Mirrors the tm-admin-list chrome so
the two catalog surfaces read as siblings. Stock states come from
classifyStock(): out (0) / low (≤ threshold) / ok. */
.tm-admin-inventory {
background: var(--as-surface);
border: 1px solid var(--as-border);
border-radius: var(--as-radius-lg);
box-shadow: var(--as-shadow);
overflow: hidden;
}
.tm-admin-inventory__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--as-space-4);
padding: var(--as-space-5) var(--as-space-6);
border-bottom: 1px solid var(--as-border);
}
.tm-admin-inventory__title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0;
}
.tm-admin-inventory__header-actions {
display: flex;
gap: var(--as-space-3);
align-items: center;
}
.tm-admin-inventory__new {
background: var(--as-accent) !important;
color: white !important;
border-color: var(--as-accent) !important;
font-weight: 600;
padding: 8px var(--as-space-4) !important;
}
.tm-admin-inventory__new:hover:not(:disabled) {
background: var(--as-accent-hover) !important;
border-color: var(--as-accent-hover) !important;
}
.tm-admin-inventory__bulk {
font-weight: 600;
}
.tm-admin-inventory__filters {
display: flex;
gap: var(--as-space-3);
align-items: center;
flex-wrap: wrap;
padding: var(--as-space-4) var(--as-space-6);
background: var(--as-bg-subtle);
border-bottom: 1px solid var(--as-border);
}
.tm-admin-inventory__search {
flex: 1;
min-width: 220px;
}
.tm-admin-inventory__product-filter {
min-width: 180px;
}
.tm-admin-inventory__low-filter {
flex-direction: row !important;
align-items: center;
gap: var(--as-space-2);
font-size: 13px;
font-weight: 500;
color: var(--as-text-muted);
cursor: pointer;
}
.tm-admin-inventory__low-filter input[type="checkbox"] {
accent-color: var(--as-accent);
width: 16px;
height: 16px;
}
.tm-admin-inventory__error {
margin: 0;
padding: var(--as-space-3) var(--as-space-6);
background: var(--as-danger-soft);
color: var(--as-danger);
font-size: 13px;
border-bottom: 1px solid var(--as-danger-soft);
}
.tm-admin-inventory__empty {
padding: var(--as-space-7) var(--as-space-6);
text-align: center;
color: var(--as-text-muted);
font-size: 14px;
}
.tm-admin-inventory__table {
width: 100%;
border-collapse: collapse;
}
.tm-admin-inventory__table thead th {
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--as-text-muted);
padding: var(--as-space-3) var(--as-space-6);
background: var(--as-bg-subtle);
border-bottom: 1px solid var(--as-border);
}
.tm-admin-inventory__table tbody td {
padding: var(--as-space-4) var(--as-space-6);
border-bottom: 1px solid var(--as-border);
vertical-align: middle;
}
.tm-admin-inventory__table tbody tr:last-child td {
border-bottom: none;
}
.tm-admin-inventory__row:hover {
background: var(--as-surface-hover);
}
.tm-admin-inventory__cell-link {
color: var(--as-text);
font-weight: 600;
text-decoration: none;
}
.tm-admin-inventory__cell-link:hover {
color: var(--as-accent);
}
.tm-admin-inventory__option-pill {
display: inline-block;
padding: 2px 8px;
margin-right: var(--as-space-1);
border-radius: 999px;
background: var(--as-bg-subtle);
border: 1px solid var(--as-border);
font-size: 11px;
font-weight: 500;
color: var(--as-text-muted);
white-space: nowrap;
}
.tm-admin-inventory__sku {
font-family: var(--as-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--as-text-muted);
}
/* Stock badge — the at-a-glance state chip. */
.tm-admin-inventory__stock-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.tm-admin-inventory__stock-badge--ok {
background: var(--as-success-soft);
color: var(--as-success);
}
.tm-admin-inventory__stock-badge--low {
background: var(--as-warning-soft);
color: var(--as-warning-text);
}
.tm-admin-inventory__stock-badge--out {
background: var(--as-danger-soft);
color: var(--as-danger);
}
.tm-admin-inventory__stock-label {
margin-left: var(--as-space-2);
font-size: 12px;
color: var(--as-text-subtle);
}
/* ── Inventory detail (`tm-inventory-detail`) ────────────────────── */
.tm-inventory-detail {
background: var(--as-surface);
border: 1px solid var(--as-border);
border-radius: var(--as-radius-lg);
box-shadow: var(--as-shadow);
padding: var(--as-space-6);
display: flex;
flex-direction: column;
gap: var(--as-space-6);
}
.tm-inventory-detail__back {
font-size: 13px;
font-weight: 600;
color: var(--as-text-muted);
text-decoration: none;
}
.tm-inventory-detail__back:hover {
color: var(--as-accent);
}
.tm-inventory-detail__header h1 {
margin: var(--as-space-2) 0 var(--as-space-1);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
.tm-inventory-detail__options {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: var(--as-space-1);
}
.tm-inventory-detail__option-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--as-bg-subtle);
border: 1px solid var(--as-border);
font-size: 11px;
font-weight: 500;
color: var(--as-text-muted);
}
/* Stock hero — big count + state + receive/adjust actions. */
.tm-inventory-detail__stock {
display: flex;
align-items: center;
gap: var(--as-space-5);
padding: var(--as-space-5);
background: var(--as-bg-subtle);
border: 1px solid var(--as-border);
border-radius: var(--as-radius-lg);
flex-wrap: wrap;
}
.tm-inventory-detail__stock-count {
font-size: 40px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.tm-inventory-detail__stock-count--ok { color: var(--as-success); }
.tm-inventory-detail__stock-count--low { color: var(--as-warning); }
.tm-inventory-detail__stock-count--out { color: var(--as-danger); }
.tm-inventory-detail__stock-meta {
flex: 1;
min-width: 160px;
}
.tm-inventory-detail__stock-state {
margin: 0;
font-weight: 600;
}
.tm-inventory-detail__stock-meta small {
color: var(--as-text-subtle);
}
.tm-inventory-detail__stock-actions {
display: flex;
gap: var(--as-space-3);
}
.tm-inventory-detail__meta {
display: flex;
flex-direction: column;
gap: var(--as-space-4);
}
.tm-inventory-detail__error {
margin: 0;
padding: var(--as-space-3) var(--as-space-4);
background: var(--as-danger-soft);
color: var(--as-danger);
font-size: 13px;
border-radius: var(--as-radius);
}
.tm-inventory-detail__save {
align-self: flex-start;
background: var(--as-accent) !important;
color: white !important;
border-color: var(--as-accent) !important;
font-weight: 600;
}
.tm-inventory-detail__save:hover:not(:disabled) {
background: var(--as-accent-hover) !important;
border-color: var(--as-accent-hover) !important;
}
/* Transaction history table. */
.tm-inventory-detail__history h2 {
font-size: 15px;
font-weight: 700;
margin: 0 0 var(--as-space-3);
}
.tm-inventory-detail__tx-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.tm-inventory-detail__tx-table thead th {
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--as-text-muted);
padding: var(--as-space-2) var(--as-space-3);
background: var(--as-bg-subtle);
border-bottom: 1px solid var(--as-border);
}
.tm-inventory-detail__tx-table tbody td {
padding: var(--as-space-2) var(--as-space-3);
border-bottom: 1px solid var(--as-border);
font-variant-numeric: tabular-nums;
}
.tm-inventory-detail__tx-table tbody tr:last-child td {
border-bottom: none;
}
.tm-inventory-detail__delta-pos { color: var(--as-success); font-weight: 700; }
.tm-inventory-detail__delta-neg { color: var(--as-danger); font-weight: 700; }
/* Danger zone. */
.tm-inventory-detail__danger {
border-top: 1px solid var(--as-border);
padding-top: var(--as-space-4);
}
.tm-inventory-detail__delete {
color: var(--as-danger) !important;
border-color: var(--as-danger-soft) !important;
font-weight: 600;
}
.tm-inventory-detail__delete:hover:not(:disabled) {
background: var(--as-danger-soft) !important;
}
/* ── Bulk receive modal (`tm-bulk-receive`) ──────────────────────── */
.tm-modal__card--wide {
max-width: 560px;
width: min(560px, calc(100vw - 2 * var(--as-space-5)));
}
.tm-bulk-receive__rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--as-space-2);
max-height: 320px;
overflow-y: auto;
}
.tm-bulk-receive__row {
display: flex;
gap: var(--as-space-2);
align-items: center;
}
.tm-bulk-receive__variant {
flex: 1;
min-width: 0;
}
.tm-bulk-receive__qty {
width: 88px;
font-variant-numeric: tabular-nums;
}
.tm-bulk-receive__remove {
color: var(--as-danger) !important;
border: none !important;
background: none !important;
font-size: 16px;
line-height: 1;
padding: var(--as-space-1) !important;
cursor: pointer;
}
.tm-bulk-receive__add {
align-self: flex-start;
margin-top: var(--as-space-2);
font-weight: 600;
}