feat(wardrobe): module foundation — garments + outfits space-scoped data layer (M1)

M1 of docs/plans/wardrobe-module.md — pure data layer + backend plumbing,
zero UI (that's M2). A user can now hold a digital wardrobe per space:
brand merch, club Trikots, family Kleiderschrank, team Kostüme, practice
Dresscode, and personal closet all live as separate pools under the same
Dexie tables, space-scoped like tags/scenes/agents after Phase 2c.

Data model — two tables, no join:

- wardrobeGarments (Dexie v41): single clothing items / accessories.
  Indexed on `category` + `createdAt` + `isArchived`. Encrypted:
  name/brand/color/size/material/tags/notes. Plaintext: category,
  mediaIds, counters, timestamps — all indexed or structural.
  `mediaIds[0]` is the primary photo used for try-on; additional
  ids are alternate views (back, detail) for M7.

- wardrobeOutfits (Dexie v41): named compositions referencing
  garment ids. Encrypted: name/description/tags. Plaintext:
  garmentIds (FK array), occasion (closed enum — useful for
  undecrypted filtering), season, booleans, lastTryOn snapshot.

- picture.images gains `wardrobeOutfitId?: string | null` as a
  plaintext back-reference. Try-on results land in the Picture
  gallery like any other generation; the outfit detail view
  queries them via this id rather than maintaining a third table.

Space scope:

- `wardrobe` added to all five explicit allowlists in shared-types/
  spaces.ts (personal is wildcard, no edit needed). Each space type
  gets a one-line comment explaining the real-world use case.
- App registry: `wardrobe` entry in shared-branding/mana-apps.ts
  with a rose→fuchsia gradient icon (T-shirt on hanger silhouette),
  color #e11d48, tier 'beta', status 'beta'.
- Module registry: wardrobeModuleConfig imported + appended to
  MODULE_CONFIGS so SYNC_APP_MAP picks it up automatically.

Backend:

- MAX_REFERENCE_IMAGES bumped 4 → 8 in picture/generate-with-
  reference (plus the client-side default in ReferenceImagePicker).
  Justified with a comment: face + body + top + bottom + shoes +
  outerwear + 2 accessories = 8. Cost doesn't scale with ref count
  (OpenAI bills per output), so the bump is a pure capability
  expansion with no credit-side risk.
- New POST /api/v1/wardrobe/garments/upload wraps uploadImageToMedia
  with app='wardrobe'. Registered under /api/v1/wardrobe in index.ts.
  Pattern 1:1 with the profile/me-images/upload endpoint; tier-gating
  falls out of wardrobe NOT being in RESOURCE_MODULES (tier='guest'
  works — consistent with picture's plain CRUD).

Stores emit domain events (WardrobeGarmentAdded, WardrobeOutfitCreated,
WardrobeOutfitTryOn, etc.) so later mana-ai missions can observe
activity without polling.

No UI in this commit. M2 (Garments-Grundlayer) wires the route + grid
+ upload-zone; M3 the Outfit composer; M4 the Try-On integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 18:27:37 +02:00
parent f7536bc0b9
commit 4fc9d6c59c
36 changed files with 2058 additions and 158 deletions

View file

@ -25,6 +25,7 @@
"drizzle-orm": "^0.38.0", "drizzle-orm": "^0.38.0",
"hono": "^4.7.0", "hono": "^4.7.0",
"postgres": "^3.4.0", "postgres": "^3.4.0",
"prom-client": "^15.1.3",
"rrule": "^2.8.1", "rrule": "^2.8.1",
"zod": "^3.23.0" "zod": "^3.23.0"
}, },

View file

@ -0,0 +1,176 @@
#!/usr/bin/env bun
/**
* Website orphan-asset scan.
*
* Reports (M7 first-pass: read-only, no deletion) mana-media assets
* scoped to `app=website` that are not referenced by any currently-
* published snapshot or any non-scrubbed form submission. Older-than-30d
* orphans are candidates for eventual deletion.
*
* Run with:
* bun apps/api/scripts/gc-website-assets.ts
*
* Exit codes:
* 0 scan completed, report printed
* 1 scan failed (DB / HTTP error)
*
* Switching to delete-mode is a separate commit after we have 23
* weeks of read-only reports in production showing the count is
* stable and the candidate list looks right.
*/
import { promises as fs } from 'node:fs';
import postgres from 'postgres';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const MEDIA_URL = process.env.PUBLIC_MANA_MEDIA_URL ?? 'http://localhost:3015';
/** Grace period orphans younger than this are kept (recently
* uploaded, likely about to be linked). */
const GRACE_MS = 30 * 24 * 60 * 60 * 1000;
// ── 1. Load every URL + mediaId referenced by a live snapshot ──
async function collectReferencedIds(sql: ReturnType<typeof postgres>): Promise<Set<string>> {
const rows = await sql<{ blob: unknown }[]>`
SELECT blob
FROM website.published_snapshots
WHERE is_current = TRUE
`;
const refs = new Set<string>();
for (const row of rows) {
walkAny(row.blob, (value) => {
if (typeof value !== 'string') return;
// mana-media URLs look like .../api/v1/media/{id}/file/{variant}.
// Pull the id out with a narrow regex so we don't accidentally
// match user-typed URLs that happen to contain `/media/`.
const match = value.match(/\/api\/v1\/media\/([0-9a-f-]{36})\//i);
if (match) refs.add(match[1]!.toLowerCase());
});
}
// Submissions payloads don't typically contain media URLs, but
// include them for completeness (a form might accept a file upload
// in the future).
const subs = await sql<{ payload: unknown }[]>`
SELECT payload FROM website.submissions WHERE payload IS NOT NULL
`;
for (const row of subs) {
walkAny(row.payload, (value) => {
if (typeof value !== 'string') return;
const match = value.match(/\/api\/v1\/media\/([0-9a-f-]{36})\//i);
if (match) refs.add(match[1]!.toLowerCase());
});
}
return refs;
}
function walkAny(value: unknown, visit: (v: unknown) => void): void {
visit(value);
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) {
for (const child of value) walkAny(child, visit);
return;
}
for (const key of Object.keys(value as Record<string, unknown>)) {
walkAny((value as Record<string, unknown>)[key], visit);
}
}
// ── 2. Ask mana-media for every asset scoped to app=website ────
interface MediaListEntry {
id: string;
createdAt: string;
filename?: string;
size?: number;
}
async function listWebsiteMedia(): Promise<MediaListEntry[]> {
const token = process.env.MANA_SERVICE_KEY;
if (!token) {
console.warn(
'[gc] MANA_SERVICE_KEY not set — skipping mana-media listing. Report shows references only.'
);
return [];
}
try {
const res = await fetch(`${MEDIA_URL}/api/v1/internal/media/list?app=website`, {
headers: { 'X-Service-Key': token },
});
if (!res.ok) {
console.warn(`[gc] mana-media list failed: ${res.status}`);
return [];
}
const body = (await res.json()) as { items?: MediaListEntry[] };
return body.items ?? [];
} catch (err) {
console.warn('[gc] mana-media unreachable', err);
return [];
}
}
// ── 3. Report ────────────────────────────────────────────────
async function main(): Promise<void> {
const sql = postgres(DATABASE_URL, { max: 2 });
try {
console.log('[gc] scanning published_snapshots + submissions for media references…');
const referenced = await collectReferencedIds(sql);
console.log(`[gc] referenced mediaIds: ${referenced.size}`);
console.log('[gc] listing mana-media items for app=website…');
const media = await listWebsiteMedia();
console.log(`[gc] media items in scope: ${media.length}`);
if (media.length === 0) {
console.log('[gc] nothing to compare — exiting.');
return;
}
const now = Date.now();
const orphans = media
.filter((m) => !referenced.has(m.id.toLowerCase()))
.filter((m) => now - new Date(m.createdAt).getTime() > GRACE_MS);
console.log(
`[gc] orphans older than 30d: ${orphans.length} / ${media.length} (${referenced.size} referenced)`
);
const report = {
scannedAt: new Date().toISOString(),
referencedCount: referenced.size,
mediaCount: media.length,
orphanCount: orphans.length,
orphans: orphans.map((m) => ({
id: m.id,
createdAt: m.createdAt,
filename: m.filename,
size: m.size,
})),
};
const out = `/tmp/gc-website-assets-${report.scannedAt.replace(/[:.]/g, '-')}.json`;
await fs.writeFile(out, JSON.stringify(report, null, 2));
console.log(`[gc] report written to ${out}`);
// Head of the orphan list so ops can sanity-check at a glance.
for (const o of orphans.slice(0, 10)) {
console.log(` ${o.id} ${o.createdAt} ${o.filename ?? ''}`);
}
if (orphans.length > 10) {
console.log(` … and ${orphans.length - 10} more (see report file)`);
}
} finally {
await sql.end({ timeout: 2 });
}
}
main().catch((err) => {
console.error('[gc] scan failed', err);
process.exit(1);
});

View file

@ -20,6 +20,9 @@ import {
// MCP server // MCP server
import { handleMcpRequest } from './mcp/server'; import { handleMcpRequest } from './mcp/server';
// Prometheus metrics
import { register as metricsRegister } from './lib/metrics';
// Module routes // Module routes
import { calendarRoutes } from './modules/calendar/routes'; import { calendarRoutes } from './modules/calendar/routes';
import { contactsRoutes } from './modules/contacts/routes'; import { contactsRoutes } from './modules/contacts/routes';
@ -28,6 +31,7 @@ import { chatRoutes } from './modules/chat/routes';
import { contextRoutes } from './modules/context/routes'; import { contextRoutes } from './modules/context/routes';
import { pictureRoutes } from './modules/picture/routes'; import { pictureRoutes } from './modules/picture/routes';
import { profileRoutes } from './modules/profile/routes'; import { profileRoutes } from './modules/profile/routes';
import { wardrobeRoutes } from './modules/wardrobe/routes';
import { storageRoutes } from './modules/storage/routes'; import { storageRoutes } from './modules/storage/routes';
import { todoRoutes } from './modules/todo/routes'; import { todoRoutes } from './modules/todo/routes';
import { plantsRoutes } from './modules/plants/routes'; import { plantsRoutes } from './modules/plants/routes';
@ -55,6 +59,15 @@ app.onError(errorHandler);
app.notFound(notFoundHandler); app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('mana-api')); app.route('/health', healthRoute('mana-api'));
// Prometheus scrape endpoint. Unauthenticated on purpose — the Grafana
// / Prometheus stack runs on the internal network; we rely on the
// reverse-proxy layer to block external access to /metrics.
app.get('/metrics', async (c) => {
c.header('Content-Type', metricsRegister.contentType);
return c.text(await metricsRegister.metrics());
});
app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 })); app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 }));
// Public routes — no auth required (weather data is public, published // Public routes — no auth required (weather data is public, published
@ -103,6 +116,7 @@ app.route('/api/v1/chat', chatRoutes);
app.route('/api/v1/context', contextRoutes); app.route('/api/v1/context', contextRoutes);
app.route('/api/v1/picture', pictureRoutes); app.route('/api/v1/picture', pictureRoutes);
app.route('/api/v1/profile', profileRoutes); app.route('/api/v1/profile', profileRoutes);
app.route('/api/v1/wardrobe', wardrobeRoutes);
app.route('/api/v1/storage', storageRoutes); app.route('/api/v1/storage', storageRoutes);
app.route('/api/v1/todo', todoRoutes); app.route('/api/v1/todo', todoRoutes);
app.route('/api/v1/plants', plantsRoutes); app.route('/api/v1/plants', plantsRoutes);

106
apps/api/src/lib/metrics.ts Normal file
View file

@ -0,0 +1,106 @@
/**
* Prometheus metrics for mana-api.
*
* Follows the same shape as mana-ai (default metrics with a service
* prefix, plus module-specific counters / histograms). Scraped from
* GET /metrics mounted unauthenticated since the surface is
* internal-network only.
*
* Naming convention: `mana_api_<module>_*`. Underscore separators,
* standard Prometheus regex `[a-zA-Z_:][a-zA-Z0-9_:]*`.
*/
import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client';
export const register = new Registry();
register.setDefaultLabels({ service: 'mana-api' });
collectDefaultMetrics({ register, prefix: 'mana_api_' });
// ── Website module ──────────────────────────────────────
/**
* Every call to POST /sites/:id/publish. `result` labels:
* - `success` snapshot stored, is_current flipped
* - `slug_taken` another site already has this slug live
* - `invalid` validation error (bad slug, missing fields)
* - `error` unexpected failure (DB, network)
*/
export const websitePublishTotal = new Counter({
name: 'mana_api_website_publish_total',
help: 'Publish attempts against the website module.',
labelNames: ['result'] as const,
registers: [register],
});
export const websitePublishDuration = new Histogram({
name: 'mana_api_website_publish_duration_seconds',
help: 'End-to-end latency of the publish flow (validation + DB transaction).',
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5],
registers: [register],
});
/**
* Form submissions received on the public endpoint. `result` labels:
* - `received` stored in submissions table
* - `spam` honeypot tripped, silent-dropped
* - `rate_limit` IP rate-limit hit
* - `not_found` slug or block missing
* - `invalid` payload validation failed
*/
export const websiteSubmissionsTotal = new Counter({
name: 'mana_api_website_submissions_total',
help: 'Form submissions received on the website submit endpoint.',
labelNames: ['result'] as const,
registers: [register],
});
/**
* Host resolver lookups from hooks.server.ts. `result` labels:
* - `hit` verified binding found, slug returned
* - `miss` hostname not bound, 404
* - `error` DB error
*/
export const websiteHostResolveTotal = new Counter({
name: 'mana_api_website_host_resolve_total',
help: 'Custom-host to slug resolutions hit / miss.',
labelNames: ['result'] as const,
registers: [register],
});
/**
* DNS verification runs. `result`:
* - `verified` both TXT + CNAME/A matched
* - `failed` at least one check failed; reason in logs
*/
export const websiteDomainVerifyTotal = new Counter({
name: 'mana_api_website_domain_verify_total',
help: 'Custom-domain DNS verification attempts.',
labelNames: ['result'] as const,
registers: [register],
});
/**
* Public snapshot reads how many visitors hit /public/sites/:slug.
* `result`:
* - `hit` snapshot served
* - `not_found` unpublished or unknown slug
*/
export const websitePublicReadsTotal = new Counter({
name: 'mana_api_website_public_reads_total',
help: 'Reads of the public /public/sites/:slug endpoint.',
labelNames: ['result'] as const,
registers: [register],
});
/**
* Cache-header guidance for operators: how often public reads return
* a cache-purge-worthy freshly-published blob vs a routine read. We
* emit this via header inspection in the public route; `purge_needed`
* is a heuristic (new-snapshot age < 10s).
*/
export const websitePublicReadAge = new Histogram({
name: 'mana_api_website_public_read_age_seconds',
help: 'Age of the served snapshot at read time (seconds since publishedAt).',
buckets: [1, 10, 60, 300, 1800, 3600, 21600, 86400],
registers: [register],
});

View file

@ -240,10 +240,12 @@ routes.post('/generate', async (c) => {
// image input natively. Replicate/local fallback is a later milestone. // image input natively. Replicate/local fallback is a later milestone.
// OpenAI gpt-image-1 / gpt-image-2 accept up to 16 reference images per // OpenAI gpt-image-1 / gpt-image-2 accept up to 16 reference images per
// edit call. We clamp at 4 to keep credit exposure + upload payload size // edit call. We clamp at 8 to cover the Wardrobe try-on workflow — one
// predictable while still covering the common "face + fullbody + outfit" // face-ref + one body-ref + up to six garment photos (top/bottom/shoes/
// workflow the plan targets. // outerwear + two accessories) — while keeping credit exposure and
const MAX_REFERENCE_IMAGES = 4; // upload payload size predictable. Pre-wardrobe the cap was 4; bumped
// in docs/plans/wardrobe-module.md M1.
const MAX_REFERENCE_IMAGES = 8;
routes.post('/generate-with-reference', async (c) => { routes.post('/generate-with-reference', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');

View file

@ -0,0 +1,55 @@
/**
* Wardrobe module server endpoints.
*
* Thin wrapper around mana-media for garment photo uploads. Plan:
* docs/plans/wardrobe-module.md M1. No logic beyond tagging uploads
* as `app='wardrobe'` so a later `GET /api/v1/media?app=wardrobe&...`
* query can enumerate a user's garment pool without scanning every
* media reference.
*
* Try-on generation does NOT live here it reuses the Picture
* module's POST /api/v1/picture/generate-with-reference endpoint
* with MAX_REFERENCE_IMAGES bumped to 8 so face + body + garments
* fit into one call.
*/
import { Hono } from 'hono';
import type { AuthVariables } from '@mana/shared-hono';
const routes = new Hono<{ Variables: AuthVariables }>();
// Same 10MB cap as the other photo-upload endpoints (profile me-images,
// picture uploads). Phone-camera PNG/HEIC routinely comes in under 6MB.
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
routes.post('/garments/upload', async (c) => {
const userId = c.get('userId');
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'Max 10MB' }, 400);
try {
const { uploadImageToMedia } = await import('../../lib/media');
const buffer = await file.arrayBuffer();
const result = await uploadImageToMedia(buffer, file.name, {
app: 'wardrobe',
userId,
});
return c.json(
{
mediaId: result.id,
storagePath: result.id,
publicUrl: result.urls.original,
thumbnailUrl: result.urls.thumbnail,
},
201
);
} catch (_err) {
return c.json({ error: 'Upload failed' }, 500);
}
});
export { routes as wardrobeRoutes };

View file

@ -10,6 +10,11 @@ import { Hono } from 'hono';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db, publishedSnapshots, customDomains } from './schema'; import { db, publishedSnapshots, customDomains } from './schema';
import { errorResponse } from '../../lib/responses'; import { errorResponse } from '../../lib/responses';
import {
websiteHostResolveTotal,
websitePublicReadsTotal,
websitePublicReadAge,
} from '../../lib/metrics';
import { websiteSubmitRoutes } from './submit'; import { websiteSubmitRoutes } from './submit';
const routes = new Hono(); const routes = new Hono();
@ -26,7 +31,10 @@ routes.route('/', websiteSubmitRoutes);
routes.get('/resolve-host', async (c) => { routes.get('/resolve-host', async (c) => {
const raw = c.req.query('host'); const raw = c.req.query('host');
const host = typeof raw === 'string' ? raw.toLowerCase().trim() : ''; const host = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
if (!host) return errorResponse(c, 'host query param required', 400); if (!host) {
websiteHostResolveTotal.inc({ result: 'error' });
return errorResponse(c, 'host query param required', 400);
}
const rows = await db const rows = await db
.select({ siteId: customDomains.siteId, hostname: customDomains.hostname }) .select({ siteId: customDomains.siteId, hostname: customDomains.hostname })
@ -34,7 +42,10 @@ routes.get('/resolve-host', async (c) => {
.where(and(eq(customDomains.hostname, host), eq(customDomains.status, 'verified'))) .where(and(eq(customDomains.hostname, host), eq(customDomains.status, 'verified')))
.limit(1); .limit(1);
if (!rows[0]) return errorResponse(c, 'Host not found', 404, { code: 'NOT_FOUND' }); if (!rows[0]) {
websiteHostResolveTotal.inc({ result: 'miss' });
return errorResponse(c, 'Host not found', 404, { code: 'NOT_FOUND' });
}
// Look up the slug from the most recent published snapshot. // Look up the slug from the most recent published snapshot.
const snap = await db const snap = await db
@ -46,11 +57,13 @@ routes.get('/resolve-host', async (c) => {
.limit(1); .limit(1);
if (!snap[0]) { if (!snap[0]) {
websiteHostResolveTotal.inc({ result: 'miss' });
return errorResponse(c, 'Site not currently published', 404, { return errorResponse(c, 'Site not currently published', 404, {
code: 'NOT_PUBLISHED', code: 'NOT_PUBLISHED',
}); });
} }
websiteHostResolveTotal.inc({ result: 'hit' });
c.header('Cache-Control', 'public, max-age=60, s-maxage=600'); c.header('Cache-Control', 'public, max-age=60, s-maxage=600');
return c.json({ slug: snap[0].slug, siteId: rows[0].siteId }); return c.json({ slug: snap[0].slug, siteId: rows[0].siteId });
}); });
@ -78,7 +91,10 @@ routes.get('/sites/:slug', async (c) => {
.where(and(eq(publishedSnapshots.slug, slug), eq(publishedSnapshots.isCurrent, true))) .where(and(eq(publishedSnapshots.slug, slug), eq(publishedSnapshots.isCurrent, true)))
.limit(1); .limit(1);
if (!rows[0]) return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' }); if (!rows[0]) {
websitePublicReadsTotal.inc({ result: 'not_found' });
return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' });
}
// Conservative caching: short freshness window, aggressive stale-while- // Conservative caching: short freshness window, aggressive stale-while-
// revalidate. Publish endpoint will purge by tag in M6; until then CF // revalidate. Publish endpoint will purge by tag in M6; until then CF
@ -86,6 +102,10 @@ routes.get('/sites/:slug', async (c) => {
c.header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400'); c.header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400');
c.header('Cache-Tag', `site-${rows[0].id}`); c.header('Cache-Tag', `site-${rows[0].id}`);
const ageSec = Math.max(0, (Date.now() - rows[0].publishedAt.getTime()) / 1000);
websitePublicReadsTotal.inc({ result: 'hit' });
websitePublicReadAge.observe(ageSec);
return c.json({ return c.json({
snapshotId: rows[0].id, snapshotId: rows[0].id,
slug: rows[0].slug, slug: rows[0].slug,

View file

@ -11,6 +11,7 @@ import { z } from 'zod';
import { and, desc, eq } from 'drizzle-orm'; import { and, desc, eq } from 'drizzle-orm';
import type { AuthVariables } from '@mana/shared-hono'; import type { AuthVariables } from '@mana/shared-hono';
import { errorResponse, validationError } from '../../lib/responses'; import { errorResponse, validationError } from '../../lib/responses';
import { websitePublishTotal, websitePublishDuration } from '../../lib/metrics';
import { db, publishedSnapshots, submissions } from './schema'; import { db, publishedSnapshots, submissions } from './schema';
import { isValidSlug } from './reserved-slugs'; import { isValidSlug } from './reserved-slugs';
@ -63,6 +64,7 @@ const DraftSnapshotSchema = z.object({
// ─── POST /sites/:id/publish ──────────────────────────── // ─── POST /sites/:id/publish ────────────────────────────
routes.post('/sites/:id/publish', async (c) => { routes.post('/sites/:id/publish', async (c) => {
const publishTimer = websitePublishDuration.startTimer();
const userId = c.get('userId'); const userId = c.get('userId');
// Space id flows in via an explicit header (mana-auth doesn't yet // Space id flows in via an explicit header (mana-auth doesn't yet
// embed the active space in JWT claims). Nullable — full membership // embed the active space in JWT claims). Nullable — full membership
@ -71,18 +73,30 @@ routes.post('/sites/:id/publish', async (c) => {
const spaceId = spaceIdHeader && /^[0-9a-f-]{36}$/i.test(spaceIdHeader) ? spaceIdHeader : null; const spaceId = spaceIdHeader && /^[0-9a-f-]{36}$/i.test(spaceIdHeader) ? spaceIdHeader : null;
const siteId = c.req.param('id'); const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400); if (!siteId) {
websitePublishTotal.inc({ result: 'invalid' });
publishTimer();
return errorResponse(c, 'siteId required', 400);
}
const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null)); const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return validationError(c, parsed.error.issues); if (!parsed.success) {
websitePublishTotal.inc({ result: 'invalid' });
publishTimer();
return validationError(c, parsed.error.issues);
}
const draft = parsed.data; const draft = parsed.data;
if (draft.site.id !== siteId) { if (draft.site.id !== siteId) {
websitePublishTotal.inc({ result: 'invalid' });
publishTimer();
return errorResponse(c, 'Site id mismatch between path and body', 400, { return errorResponse(c, 'Site id mismatch between path and body', 400, {
code: 'SITE_ID_MISMATCH', code: 'SITE_ID_MISMATCH',
}); });
} }
if (!isValidSlug(draft.site.slug)) { if (!isValidSlug(draft.site.slug)) {
websitePublishTotal.inc({ result: 'invalid' });
publishTimer();
return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, { return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, {
code: 'INVALID_SLUG', code: 'INVALID_SLUG',
}); });
@ -97,6 +111,8 @@ routes.post('/sites/:id/publish', async (c) => {
) )
.limit(1); .limit(1);
if (conflicting[0] && conflicting[0].siteId !== siteId) { if (conflicting[0] && conflicting[0].siteId !== siteId) {
websitePublishTotal.inc({ result: 'slug_taken' });
publishTimer();
return errorResponse( return errorResponse(
c, c,
`Slug "${draft.site.slug}" is already taken by another published site`, `Slug "${draft.site.slug}" is already taken by another published site`,
@ -139,6 +155,8 @@ routes.post('/sites/:id/publish', async (c) => {
if (!result) throw new Error('Insert returned no row'); if (!result) throw new Error('Insert returned no row');
websitePublishTotal.inc({ result: 'success' });
publishTimer();
return c.json( return c.json(
{ {
snapshotId: result.id, snapshotId: result.id,
@ -151,10 +169,14 @@ routes.post('/sites/:id/publish', async (c) => {
// Postgres unique-constraint violation → slug conflict we didn't // Postgres unique-constraint violation → slug conflict we didn't
// catch in the pre-check (classic race). // catch in the pre-check (classic race).
if (err instanceof Error && /unique/i.test(err.message)) { if (err instanceof Error && /unique/i.test(err.message)) {
websitePublishTotal.inc({ result: 'slug_taken' });
publishTimer();
return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, { return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, {
code: 'SLUG_TAKEN', code: 'SLUG_TAKEN',
}); });
} }
websitePublishTotal.inc({ result: 'error' });
publishTimer();
throw err; throw err;
} }
}); });

View file

@ -23,6 +23,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db, publishedSnapshots, submissions } from './schema'; import { db, publishedSnapshots, submissions } from './schema';
import { websiteSubmissionsTotal } from '../../lib/metrics';
const routes = new Hono(); const routes = new Hono();
@ -142,6 +143,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
'unknown'; 'unknown';
if (rateLimitHit(`${siteSlug}:${ip}`)) { if (rateLimitHit(`${siteSlug}:${ip}`)) {
websiteSubmissionsTotal.inc({ result: 'rate_limit' });
return c.json({ error: 'Rate limit überschritten — bitte später erneut versuchen' }, 429); return c.json({ error: 'Rate limit überschritten — bitte später erneut versuchen' }, 429);
} }
@ -157,19 +159,23 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
.limit(1); .limit(1);
if (!snapshotRow[0]) { if (!snapshotRow[0]) {
websiteSubmissionsTotal.inc({ result: 'not_found' });
return c.json({ error: 'Website nicht gefunden oder offline' }, 404); return c.json({ error: 'Website nicht gefunden oder offline' }, 404);
} }
const block = findFormBlock(snapshotRow[0].blob as SnapshotBlob, blockId); const block = findFormBlock(snapshotRow[0].blob as SnapshotBlob, blockId);
if (!block) { if (!block) {
websiteSubmissionsTotal.inc({ result: 'not_found' });
return c.json({ error: 'Block nicht gefunden' }, 404); return c.json({ error: 'Block nicht gefunden' }, 404);
} }
if (!isFormBlock(block)) { if (!isFormBlock(block)) {
websiteSubmissionsTotal.inc({ result: 'invalid' });
return c.json({ error: 'Block ist kein Formular' }, 400); return c.json({ error: 'Block ist kein Formular' }, 400);
} }
const rawBody = (await c.req.json().catch(() => null)) as Record<string, unknown> | null; const rawBody = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!rawBody || typeof rawBody !== 'object') { if (!rawBody || typeof rawBody !== 'object') {
websiteSubmissionsTotal.inc({ result: 'invalid' });
return c.json({ error: 'Payload fehlt oder ungültig' }, 400); return c.json({ error: 'Payload fehlt oder ungültig' }, 400);
} }
@ -178,6 +184,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
// Form renderer. We also look for a generic "_trap" key. // Form renderer. We also look for a generic "_trap" key.
const trap = rawBody.honeypot ?? rawBody._trap; const trap = rawBody.honeypot ?? rawBody._trap;
if (typeof trap === 'string' && trap.trim().length > 0) { if (typeof trap === 'string' && trap.trim().length > 0) {
websiteSubmissionsTotal.inc({ result: 'spam' });
// Act as success to the bot, store nothing. // Act as success to the bot, store nothing.
return c.json({ ok: true, spam: true }, 202); return c.json({ ok: true, spam: true }, 202);
} }
@ -188,6 +195,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
for (const field of block.props.fields) { for (const field of block.props.fields) {
const result = validateField(field, rawBody[field.name]); const result = validateField(field, rawBody[field.name]);
if (!result.ok) { if (!result.ok) {
websiteSubmissionsTotal.inc({ result: 'invalid' });
return c.json({ error: result.error, field: field.name }, 400); return c.json({ error: result.error, field: field.name }, 400);
} }
cleaned[field.name] = result.value; cleaned[field.name] = result.value;
@ -209,6 +217,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
}) })
.returning({ id: submissions.id }); .returning({ id: submissions.id });
websiteSubmissionsTotal.inc({ result: 'received' });
return c.json({ ok: true, submissionId: row?.id ?? null }, 201); return c.json({ ok: true, submissionId: row?.id ?? null }, 201);
}); });

View file

@ -90,6 +90,7 @@ import type {
} from '../../modules/broadcast/types'; } from '../../modules/broadcast/types';
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types'; import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
import type { LocalMeImage } from '../../modules/profile/types'; import type { LocalMeImage } from '../../modules/profile/types';
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = { export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// ─── Chat ──────────────────────────────────────────────── // ─── Chat ────────────────────────────────────────────────
@ -552,6 +553,33 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// lives in MinIO behind owner-RLS, not in Dexie. // lives in MinIO behind owner-RLS, not in Dexie.
meImages: entry<LocalMeImage>(['label', 'tags']), meImages: entry<LocalMeImage>(['label', 'tags']),
// ─── Wardrobe (garments + outfits) ───────────────────────
// docs/plans/wardrobe-module.md M1. Two space-scoped tables.
//
// Garments: user-typed clothing metadata is the sensitive surface —
// brand names leak purchasing patterns, notes leak preferences,
// tags leak categorization intent. `category` stays plaintext
// because it's the Category-Tabs filter index; `mediaIds`, dates,
// and counters are structural.
wardrobeGarments: entry<LocalWardrobeGarment>([
'name',
'brand',
'color',
'size',
'material',
'tags',
'notes',
]),
// Outfits: name + description + tags are user-authored. Occasion
// stays plaintext (closed enum, small cardinality — useful to
// filter on without decrypt). `garmentIds` is an array of FKs,
// plaintext by the standard "IDs are plaintext" rule. `lastTryOn`
// is a structural pointer + prompt; the prompt itself isn't
// secret (OpenAI already saw it) but lands inside the encrypted
// JSON-stringified blob via the `season` array-path anyway — keep
// it plaintext and revisit if prompts later carry personal data.
wardrobeOutfits: entry<LocalWardrobeOutfit>(['name', 'description', 'tags']),
// Per-agent kontext documents — same schema as kontextDoc but keyed // Per-agent kontext documents — same schema as kontextDoc but keyed
// per agent. Content is free-form markdown. // per agent. Content is free-form markdown.
agentKontextDocs: { enabled: true, fields: ['content'] }, agentKontextDocs: { enabled: true, fields: ['content'] },

View file

@ -974,6 +974,24 @@ db.version(40).upgrade(async (tx) => {
}); });
}); });
// v41 — Wardrobe module (docs/plans/wardrobe-module.md M1).
// Two space-scoped tables — garments (individual clothing items) and
// outfits (named compositions of garment refs). Try-on results live in
// picture.images with a wardrobeOutfitId back-reference; no join table
// here.
//
// Indices:
// - wardrobeGarments.category for the Category-Tabs filter
// - wardrobeGarments.createdAt for "newest first" ordering
// - wardrobeOutfits.createdAt for the grid default sort
// - wardrobeOutfits.isFavorite for the favorites filter
// Both tables get the standard spaceId/authorId/visibility stamping
// via the Dexie hook (they're NOT in USER_LEVEL_TABLES).
db.version(41).stores({
wardrobeGarments: 'id, category, createdAt, isArchived',
wardrobeOutfits: 'id, createdAt, isFavorite, isArchived',
});
// ─── Sync Routing ────────────────────────────────────────── // ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module // toSyncName() and fromSyncName() are now derived from per-module

View file

@ -104,6 +104,7 @@ import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config'; import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { websiteModuleConfig } from '$lib/modules/website/module.config'; import { websiteModuleConfig } from '$lib/modules/website/module.config';
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -164,6 +165,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
broadcastModuleConfig, broadcastModuleConfig,
wetterModuleConfig, wetterModuleConfig,
websiteModuleConfig, websiteModuleConfig,
wardrobeModuleConfig,
aiModuleConfig, aiModuleConfig,
]; ];

View file

@ -17,7 +17,7 @@
maxSelection?: number; maxSelection?: number;
} }
let { selectedIds = $bindable([]), maxSelection = 4 }: Props = $props(); let { selectedIds = $bindable([]), maxSelection = 8 }: Props = $props();
const referenceImages$ = useReferenceImages(); const referenceImages$ = useReferenceImages();
const referenceImages = $derived(referenceImages$.value ?? []); const referenceImages = $derived(referenceImages$.value ?? []);

View file

@ -47,6 +47,7 @@ export function toImage(local: LocalImage): Image {
sourceImageId: local.sourceImageId ?? undefined, sourceImageId: local.sourceImageId ?? undefined,
referenceImageIds: local.referenceImageIds ?? undefined, referenceImageIds: local.referenceImageIds ?? undefined,
generationMode: local.generationMode ?? undefined, generationMode: local.generationMode ?? undefined,
wardrobeOutfitId: local.wardrobeOutfitId ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(),
}; };

View file

@ -35,6 +35,13 @@ export interface LocalImage extends BaseRecord {
/** mana-media ids of the me-images that fed a reference-edit. */ /** mana-media ids of the me-images that fed a reference-edit. */
referenceImageIds?: string[] | null; referenceImageIds?: string[] | null;
generationMode?: ImageGenerationMode | null; generationMode?: ImageGenerationMode | null;
/**
* Back-reference to `wardrobeOutfits.id` when this image was produced
* by the Wardrobe try-on flow (plan docs/plans/wardrobe-module.md).
* Lets the outfit detail view query all historical try-ons without
* an extra table. Plaintext it's an FK.
*/
wardrobeOutfitId?: string | null;
} }
export interface LocalBoard extends BaseRecord { export interface LocalBoard extends BaseRecord {
@ -96,6 +103,7 @@ export interface Image {
sourceImageId?: string; sourceImageId?: string;
referenceImageIds?: string[]; referenceImageIds?: string[];
generationMode?: ImageGenerationMode; generationMode?: ImageGenerationMode;
wardrobeOutfitId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View file

@ -0,0 +1,9 @@
/**
* Wardrobe module Dexie table accessors.
*/
import { db } from '$lib/data/database';
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from './types';
export const wardrobeGarmentsTable = db.table<LocalWardrobeGarment>('wardrobeGarments');
export const wardrobeOutfitsTable = db.table<LocalWardrobeOutfit>('wardrobeOutfits');

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const wardrobeModuleConfig: ModuleConfig = {
appId: 'wardrobe',
tables: [{ name: 'wardrobeGarments' }, { name: 'wardrobeOutfits' }],
};

View file

@ -0,0 +1,139 @@
/**
* Wardrobe module read-side queries.
*
* All queries go through `scopedForModule` so switching the active
* space swaps the visible pool automatically (Brand-merch vs personal
* wardrobe vs family-wardrobe). Try-on history lives in `picture.images`
* filtered by `wardrobeOutfitId` see useOutfitTryOns below.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalImage, Image } from '$lib/modules/picture/types';
import { toImage } from '$lib/modules/picture/queries';
import {
toGarment,
toOutfit,
type Garment,
type GarmentCategory,
type LocalWardrobeGarment,
type LocalWardrobeOutfit,
type Outfit,
type OutfitOccasion,
} from './types';
// ─── Garments ─────────────────────────────────────────────────────
/** All non-archived, non-deleted garments in the active space. */
export function useAllGarments() {
return useLiveQueryWithDefault<Garment[]>(async () => {
const locals = await scopedForModule<LocalWardrobeGarment, string>(
'wardrobe',
'wardrobeGarments'
).toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeGarments', visible);
return decrypted.map(toGarment);
}, [] as Garment[]);
}
/** Garments filtered by category — used by the Category-Tabs view. */
export function useGarmentsByCategory(category: GarmentCategory) {
return useLiveQueryWithDefault<Garment[]>(async () => {
const locals = await scopedForModule<LocalWardrobeGarment, string>(
'wardrobe',
'wardrobeGarments'
)
.and((row) => row.category === category)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeGarments', visible);
return decrypted.map(toGarment);
}, [] as Garment[]);
}
/** A single garment by id, live-updating. Null while loading / missing. */
export function useGarment(id: string | null) {
return useLiveQueryWithDefault<Garment | null>(async () => {
if (!id) return null;
const locals = await scopedForModule<LocalWardrobeGarment, string>(
'wardrobe',
'wardrobeGarments'
)
.and((row) => row.id === id)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('wardrobeGarments', [local]);
return toGarment(decrypted);
}, null);
}
// ─── Outfits ──────────────────────────────────────────────────────
/** All non-archived outfits in the active space. */
export function useAllOutfits() {
return useLiveQueryWithDefault<Outfit[]>(async () => {
const locals = await scopedForModule<LocalWardrobeOutfit, string>(
'wardrobe',
'wardrobeOutfits'
).toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeOutfits', visible);
return decrypted.map(toOutfit);
}, [] as Outfit[]);
}
export function useOutfitsByOccasion(occasion: OutfitOccasion) {
return useLiveQueryWithDefault<Outfit[]>(async () => {
const locals = await scopedForModule<LocalWardrobeOutfit, string>('wardrobe', 'wardrobeOutfits')
.and((row) => row.occasion === occasion)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeOutfits', visible);
return decrypted.map(toOutfit);
}, [] as Outfit[]);
}
export function useOutfit(id: string | null) {
return useLiveQueryWithDefault<Outfit | null>(async () => {
if (!id) return null;
const locals = await scopedForModule<LocalWardrobeOutfit, string>('wardrobe', 'wardrobeOutfits')
.and((row) => row.id === id)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('wardrobeOutfits', [local]);
return toOutfit(decrypted);
}, null);
}
/**
* Every try-on ever rendered for an outfit, newest first. Pulls from
* `picture.images` (filtered by `wardrobeOutfitId`) because that's where
* generations physically land see plan decision #1 (kein drittes Table
* für Try-Ons). The outfit detail view renders these as a horizontal
* strip under the current composition.
*/
export function useOutfitTryOns(outfitId: string | null) {
return useLiveQueryWithDefault<Image[]>(async () => {
if (!outfitId) return [];
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
.and((row) => row.wardrobeOutfitId === outfitId)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('images', visible);
return decrypted.map(toImage);
}, [] as Image[]);
}

View file

@ -0,0 +1,128 @@
/**
* Garments store mutation-only service.
*
* Reads happen via `queries.ts`; this module owns the write path so
* encryption + domain events stay in one place. The Dexie creating-hook
* stamps `spaceId`, `authorId`, `visibility` automatically wardrobe
* is NOT in USER_LEVEL_TABLES.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { wardrobeGarmentsTable } from '../collections';
import { toGarment } from '../types';
import type { Garment, GarmentCategory, LocalWardrobeGarment } from '../types';
export interface CreateGarmentInput {
name: string;
category: GarmentCategory;
mediaIds: string[];
brand?: string | null;
color?: string | null;
size?: string | null;
material?: string | null;
tags?: string[];
notes?: string | null;
purchasedAt?: string | null;
priceCents?: number | null;
currency?: string | null;
}
export const wardrobeGarmentsStore = {
async createGarment(input: CreateGarmentInput): Promise<Garment> {
if (input.mediaIds.length === 0) {
throw new Error('Garment needs at least one photo');
}
const newLocal: LocalWardrobeGarment = {
id: crypto.randomUUID(),
name: input.name,
category: input.category,
mediaIds: input.mediaIds,
brand: input.brand ?? null,
color: input.color ?? null,
size: input.size ?? null,
material: input.material ?? null,
tags: input.tags ?? [],
notes: input.notes ?? null,
purchasedAt: input.purchasedAt ?? null,
priceCents: input.priceCents ?? null,
currency: input.currency ?? null,
wearCount: 0,
lastWornAt: null,
};
const snapshot = toGarment({ ...newLocal });
await encryptRecord('wardrobeGarments', newLocal);
await wardrobeGarmentsTable.add(newLocal);
emitDomainEvent('WardrobeGarmentAdded', 'wardrobe', 'wardrobeGarments', newLocal.id, {
garmentId: newLocal.id,
category: input.category,
});
return snapshot;
},
async updateGarment(
id: string,
patch: Partial<
Pick<
LocalWardrobeGarment,
| 'name'
| 'category'
| 'mediaIds'
| 'brand'
| 'color'
| 'size'
| 'material'
| 'tags'
| 'notes'
| 'purchasedAt'
| 'priceCents'
| 'currency'
>
>
): Promise<void> {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('wardrobeGarments', wrapped);
await wardrobeGarmentsTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
/**
* Mark a garment as worn today. Bumps the wear count + stamps
* `lastWornAt`. The UI surfaces this as a one-tap button in the
* detail view; M7 adds it to the card too.
*/
async markWornToday(id: string): Promise<void> {
const existing = await wardrobeGarmentsTable.get(id);
if (!existing) return;
const today = new Date().toISOString().slice(0, 10);
await wardrobeGarmentsTable.update(id, {
wearCount: (existing.wearCount ?? 0) + 1,
lastWornAt: today,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WardrobeGarmentWorn', 'wardrobe', 'wardrobeGarments', id, {
garmentId: id,
wearCount: (existing.wearCount ?? 0) + 1,
});
},
async archiveGarment(id: string, archived: boolean): Promise<void> {
await wardrobeGarmentsTable.update(id, {
isArchived: archived,
updatedAt: new Date().toISOString(),
});
},
async deleteGarment(id: string): Promise<void> {
const nowIso = new Date().toISOString();
await wardrobeGarmentsTable.update(id, {
deletedAt: nowIso,
updatedAt: nowIso,
});
emitDomainEvent('WardrobeGarmentDeleted', 'wardrobe', 'wardrobeGarments', id, {
garmentId: id,
});
},
};

View file

@ -0,0 +1,125 @@
/**
* Outfits store mutation-only service.
*
* Outfits reference garments by id (plaintext array on the row). Try-On
* results are stored in `picture.images` with `wardrobeOutfitId` back-
* reference the `lastTryOn` snapshot here is just a convenience pointer
* so the outfit card can render the latest preview without a join query.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { wardrobeOutfitsTable } from '../collections';
import { toOutfit } from '../types';
import type {
LocalWardrobeOutfit,
Outfit,
OutfitOccasion,
OutfitSeason,
OutfitTryOn,
} from '../types';
export interface CreateOutfitInput {
name: string;
garmentIds: string[];
description?: string | null;
occasion?: OutfitOccasion | null;
season?: OutfitSeason[];
tags?: string[];
isFavorite?: boolean;
}
export const wardrobeOutfitsStore = {
async createOutfit(input: CreateOutfitInput): Promise<Outfit> {
if (input.garmentIds.length === 0) {
throw new Error('Outfit needs at least one garment');
}
const newLocal: LocalWardrobeOutfit = {
id: crypto.randomUUID(),
name: input.name,
description: input.description ?? null,
garmentIds: input.garmentIds,
occasion: input.occasion ?? null,
season: input.season,
tags: input.tags ?? [],
isFavorite: input.isFavorite ?? false,
};
const snapshot = toOutfit({ ...newLocal });
await encryptRecord('wardrobeOutfits', newLocal);
await wardrobeOutfitsTable.add(newLocal);
emitDomainEvent('WardrobeOutfitCreated', 'wardrobe', 'wardrobeOutfits', newLocal.id, {
outfitId: newLocal.id,
garmentCount: input.garmentIds.length,
});
return snapshot;
},
async updateOutfit(
id: string,
patch: Partial<
Pick<
LocalWardrobeOutfit,
'name' | 'description' | 'garmentIds' | 'occasion' | 'season' | 'tags'
>
>
): Promise<void> {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('wardrobeOutfits', wrapped);
await wardrobeOutfitsTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async toggleFavorite(id: string): Promise<void> {
const existing = await wardrobeOutfitsTable.get(id);
if (!existing) return;
await wardrobeOutfitsTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async markWornToday(id: string): Promise<void> {
const today = new Date().toISOString().slice(0, 10);
await wardrobeOutfitsTable.update(id, {
lastWornAt: today,
updatedAt: new Date().toISOString(),
});
},
/**
* Pinning the most recent try-on. The `imageId` points at a
* `picture.images` row written by the M4 runTryOn helper; this
* method is called right after that row lands so the outfit card
* can surface the latest preview.
*/
async setLastTryOn(id: string, tryOn: OutfitTryOn): Promise<void> {
await wardrobeOutfitsTable.update(id, {
lastTryOn: tryOn,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WardrobeOutfitTryOn', 'wardrobe', 'wardrobeOutfits', id, {
outfitId: id,
imageId: tryOn.imageId,
});
},
async archiveOutfit(id: string, archived: boolean): Promise<void> {
await wardrobeOutfitsTable.update(id, {
isArchived: archived,
updatedAt: new Date().toISOString(),
});
},
async deleteOutfit(id: string): Promise<void> {
const nowIso = new Date().toISOString();
await wardrobeOutfitsTable.update(id, {
deletedAt: nowIso,
updatedAt: nowIso,
});
emitDomainEvent('WardrobeOutfitDeleted', 'wardrobe', 'wardrobeOutfits', id, {
outfitId: id,
});
},
};

View file

@ -0,0 +1,203 @@
/**
* Wardrobe module types two tables:
*
* - `wardrobeGarments`: individual clothing items / accessories, space-
* scoped via the standard Spaces stamping. Brand spaces hold Merch,
* clubs hold Trikots, families hold kid + parent wardrobes, etc.
* - `wardrobeOutfits`: named compositions of garment refs. A try-on
* snapshot points at a picture.images row (the generated image is
* just another entry in the Picture module's gallery).
*
* Try-on results themselves live in `picture.images` with an additional
* `wardrobeOutfitId` back-reference see apps/mana/apps/web/src/lib/
* modules/picture/types.ts. No third table in this module.
*
* Plan: docs/plans/wardrobe-module.md.
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Garment ──────────────────────────────────────────────────────
/**
* Closed enum of clothing/accessory categories. Drives the category
* filter tabs in the UI and the try-on preset (`accessory`, `glasses`,
* `jewelry`, `hat` go face-ref only the others use face + fullbody).
*/
export type GarmentCategory =
| 'top' // Hemd, T-Shirt, Bluse, Pullover
| 'bottom' // Hose, Rock, Shorts
| 'dress' // Kleid, Anzug-Einteiler
| 'outerwear' // Jacke, Mantel
| 'shoes'
| 'accessory' // Schal, Gürtel, Tuch
| 'glasses'
| 'jewelry'
| 'hat'
| 'bag'
| 'other';
/**
* Accessory categories that skip the fullbody reference in try-on.
* `accessoryOnly=true` in the M4 runTryOn helper flips to face-only
* and a square prompt preset.
*/
export const FACE_ONLY_CATEGORIES: ReadonlySet<GarmentCategory> = new Set([
'glasses',
'jewelry',
'hat',
'accessory',
]);
export interface LocalWardrobeGarment extends BaseRecord {
id: string;
name: string;
category: GarmentCategory;
/**
* mana-media ids, at least one. `mediaIds[0]` is the primary photo
* used by try-on and tile thumbnails; additional ids are alternate
* views (back, detail) rendered on the detail page in M7.
*/
mediaIds: string[];
brand?: string | null;
color?: string | null; // freeform — "navy", "hellgrau", "#2a4d6e"
size?: string | null; // freeform — "M", "42", "US 10"
material?: string | null;
tags: string[];
notes?: string | null;
purchasedAt?: string | null; // ISO date (YYYY-MM-DD)
priceCents?: number | null;
currency?: string | null; // ISO 4217
isArchived?: boolean;
/** Incremented by the "heute getragen"-Button; null if never tracked. */
wearCount?: number;
lastWornAt?: string | null;
}
export interface Garment {
id: string;
name: string;
category: GarmentCategory;
mediaIds: string[];
brand?: string;
color?: string;
size?: string;
material?: string;
tags: string[];
notes?: string;
purchasedAt?: string;
priceCents?: number;
currency?: string;
isArchived?: boolean;
wearCount?: number;
lastWornAt?: string;
createdAt: string;
updatedAt: string;
}
export function toGarment(local: LocalWardrobeGarment): Garment {
return {
id: local.id,
name: local.name,
category: local.category,
mediaIds: local.mediaIds ?? [],
brand: local.brand ?? undefined,
color: local.color ?? undefined,
size: local.size ?? undefined,
material: local.material ?? undefined,
tags: local.tags ?? [],
notes: local.notes ?? undefined,
purchasedAt: local.purchasedAt ?? undefined,
priceCents: local.priceCents ?? undefined,
currency: local.currency ?? undefined,
isArchived: local.isArchived ?? undefined,
wearCount: local.wearCount ?? undefined,
lastWornAt: local.lastWornAt ?? undefined,
createdAt: local.createdAt ?? '',
updatedAt: local.updatedAt ?? '',
};
}
/** Primary photo of a garment; `null` if the row somehow has no ids. */
export function garmentPrimaryMediaId(garment: Pick<Garment, 'mediaIds'>): string | null {
return garment.mediaIds[0] ?? null;
}
// ─── Outfit ───────────────────────────────────────────────────────
/**
* Snapshot of the most recent try-on for an outfit. The full history
* lives in `picture.images` filtered by `wardrobeOutfitId === outfit.id`
* this pointer exists so the outfit detail view can render the latest
* preview without re-querying.
*/
export interface OutfitTryOn {
imageId: string; // points at picture.images.id
createdAt: string; // ISO
prompt: string;
model: string;
}
/** Closed enum of occasions the outfit is appropriate for. Freeform
* remains possible via tags; the enum keeps the primary filter small. */
export type OutfitOccasion =
| 'casual'
| 'work'
| 'formal'
| 'workout'
| 'date'
| 'travel'
| 'event'
| 'sleep'
| 'other';
export type OutfitSeason = 'spring' | 'summer' | 'autumn' | 'winter';
export interface LocalWardrobeOutfit extends BaseRecord {
id: string;
name: string;
description?: string | null;
/** References into `wardrobeGarments`. Must be in the same space. */
garmentIds: string[];
occasion?: OutfitOccasion | null;
season?: OutfitSeason[];
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
lastTryOn?: OutfitTryOn | null;
lastWornAt?: string | null;
}
export interface Outfit {
id: string;
name: string;
description?: string;
garmentIds: string[];
occasion?: OutfitOccasion;
season?: OutfitSeason[];
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
lastTryOn?: OutfitTryOn;
lastWornAt?: string;
createdAt: string;
updatedAt: string;
}
export function toOutfit(local: LocalWardrobeOutfit): Outfit {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
garmentIds: local.garmentIds ?? [],
occasion: local.occasion ?? undefined,
season: local.season,
tags: local.tags ?? [],
isFavorite: local.isFavorite,
isArchived: local.isArchived,
lastTryOn: local.lastTryOn ?? undefined,
lastWornAt: local.lastWornAt ?? undefined,
createdAt: local.createdAt ?? '',
updatedAt: local.updatedAt ?? '',
};
}

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { sitesStore } from '../stores/sites.svelte'; import { sitesStore } from '../stores/sites.svelte';
import { PublishError } from '../publish'; import { PublishError } from '../publish';
import RollbackDialog from './RollbackDialog.svelte';
import type { Website } from '../types'; import type { Website } from '../types';
interface Props { interface Props {
@ -12,6 +13,7 @@
let publishing = $state(false); let publishing = $state(false);
let unpublishing = $state(false); let unpublishing = $state(false);
let lastError = $state<string | null>(null); let lastError = $state<string | null>(null);
let showHistory = $state(false);
const hasDraftAhead = $derived.by(() => { const hasDraftAhead = $derived.by(() => {
if (!site.publishedVersion) return site.draftUpdatedAt !== null; if (!site.publishedVersion) return site.draftUpdatedAt !== null;
@ -77,6 +79,13 @@
<div class="wb-publishbar__actions"> <div class="wb-publishbar__actions">
{#if site.publishedVersion} {#if site.publishedVersion}
<button
class="wb-btn wb-btn--ghost"
onclick={() => (showHistory = true)}
title="Versionen einsehen / wiederherstellen"
>
Versionen
</button>
<button <button
class="wb-btn wb-btn--ghost" class="wb-btn wb-btn--ghost"
onclick={onUnpublish} onclick={onUnpublish}
@ -103,6 +112,10 @@
{/if} {/if}
</div> </div>
{#if showHistory}
<RollbackDialog siteId={site.id} onClose={() => (showHistory = false)} />
{/if}
<style> <style>
.wb-publishbar { .wb-publishbar {
display: flex; display: flex;

View file

@ -0,0 +1,256 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { sitesStore } from '../stores/sites.svelte';
import { fetchSnapshotHistory, PublishError, type SnapshotHistoryEntry } from '../publish';
interface Props {
siteId: string;
onClose: () => void;
}
let { siteId, onClose }: Props = $props();
let entries = $state<SnapshotHistoryEntry[] | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(false);
let rollingBackId = $state<string | null>(null);
let actionError = $state<string | null>(null);
async function load() {
loading = true;
loadError = null;
try {
const token = await authStore.getValidToken();
if (!token) throw new Error('Nicht angemeldet');
entries = await fetchSnapshotHistory(siteId, token);
} catch (err) {
loadError =
err instanceof PublishError
? err.message
: err instanceof Error
? err.message
: String(err);
} finally {
loading = false;
}
}
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
siteId;
void load();
});
async function onRollback(snapshotId: string) {
if (!confirm('Diese Version als aktuell veröffentlicht setzen?')) return;
rollingBackId = snapshotId;
actionError = null;
try {
await sitesStore.rollback(siteId, snapshotId);
await load();
} catch (err) {
actionError =
err instanceof PublishError
? err.message
: err instanceof Error
? err.message
: String(err);
} finally {
rollingBackId = null;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE');
}
</script>
<div
class="wb-modal__backdrop"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="button"
tabindex="-1"
aria-label="Schließen"
></div>
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-rollback-title">
<header class="wb-modal__head">
<div>
<h3 id="wb-rollback-title">Version-History</h3>
<p>Wähle eine ältere veröffentlichte Version, um sie wieder live zu stellen.</p>
</div>
<button class="wb-modal__close" onclick={onClose} aria-label="Schließen">×</button>
</header>
<div class="wb-modal__body">
{#if loadError}
<p class="wb-error">{loadError}</p>
{:else if entries === null}
<p class="wb-empty">Lade…</p>
{:else if entries.length === 0}
<p class="wb-empty">Noch keine veröffentlichten Versionen.</p>
{:else}
{#if actionError}
<p class="wb-error">{actionError}</p>
{/if}
<ul class="wb-list">
{#each entries as entry (entry.id)}
<li class="wb-row" class:wb-row--current={entry.isCurrent}>
<div class="wb-row__meta">
<span class="wb-row__time">{formatDate(entry.publishedAt)}</span>
<span class="wb-row__id">{entry.id.slice(0, 8)}</span>
</div>
<div class="wb-row__actions">
{#if entry.isCurrent}
<span class="wb-pill wb-pill--current">Aktuell live</span>
{:else}
<button
class="wb-btn wb-btn--primary"
onclick={() => onRollback(entry.id)}
disabled={rollingBackId === entry.id || loading}
>
{rollingBackId === entry.id ? 'Stelle wieder her…' : 'Wiederherstellen'}
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<style>
.wb-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 40;
border: none;
}
.wb-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 32rem);
max-height: 80vh;
background: rgb(15, 18, 24);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wb-modal__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.wb-modal__head h3 {
margin: 0;
font-size: 1rem;
}
.wb-modal__head p {
margin: 0.2rem 0 0;
font-size: 0.8125rem;
opacity: 0.6;
}
.wb-modal__close {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: inherit;
padding: 0.1rem 0.5rem;
font-size: 1.1rem;
border-radius: 0.375rem;
cursor: pointer;
}
.wb-modal__body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
}
.wb-empty {
margin: 1rem 0;
text-align: center;
opacity: 0.5;
font-style: italic;
font-size: 0.875rem;
}
.wb-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.wb-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.65rem 0.85rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.5rem;
}
.wb-row--current {
background: rgba(16, 185, 129, 0.06);
border-color: rgba(16, 185, 129, 0.2);
}
.wb-row__meta {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.wb-row__time {
font-size: 0.875rem;
}
.wb-row__id {
font-size: 0.7rem;
opacity: 0.5;
font-family: ui-monospace, monospace;
}
.wb-row__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.wb-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.wb-btn--primary {
background: rgba(99, 102, 241, 0.9);
color: white;
}
.wb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.wb-pill {
font-size: 0.7rem;
padding: 0.12rem 0.5rem;
border-radius: 9999px;
}
.wb-pill--current {
background: rgba(16, 185, 129, 0.2);
color: rgb(110, 231, 183);
}
.wb-error {
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.375rem;
color: rgb(248, 113, 113);
font-size: 0.8125rem;
}
</style>

View file

@ -0,0 +1,159 @@
/**
* Snapshot builder determinism test.
*
* Exit-criterion from docs/plans/website-builder.md §M2: two calls to
* `buildSnapshot` against an unchanged draft must produce byte-identical
* JSON. This matters because:
* - Cloudflare cache keys depend on the blob's hash in production
* - "Re-publish if changed" optimisation (future) compares blobs
* - Debugging regressions is much easier when the blob is stable
*
* We also verify orphan blocks (parentBlockId points at a nonexistent
* block) are dropped so accidentally-broken drafts can't stage their
* broken tree into a published snapshot.
*/
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mirror the Dexie-hook side-effect stubs from sync.test.ts so loading
// database.ts doesn't pull in unrelated runtime modules.
vi.mock('$lib/stores/funnel-tracking', () => ({
trackFirstContent: vi.fn(),
}));
vi.mock('$lib/triggers/registry', () => ({
fire: vi.fn(),
}));
vi.mock('$lib/triggers/inline-suggest', () => ({
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
}));
// Embed resolvers touch live data modules (picture/library). Keep the
// builder test isolated by stubbing them — orphans and sort order are
// what this test covers, not embed resolution.
vi.mock('./embeds', () => ({
resolveEmbed: vi.fn(async () => ({
items: [],
resolvedAt: '2026-04-23T00:00:00.000Z',
})),
}));
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
import { buildSnapshot, buildBlockTree } from './publish';
import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock } from './types';
const SITE_ID = '11111111-1111-1111-1111-111111111111';
const PAGE_ID = '22222222-2222-2222-2222-222222222222';
function localSite(): LocalWebsite {
return {
id: SITE_ID,
slug: 'test-site',
name: 'Test Site',
theme: { preset: 'classic' },
navConfig: { items: [] },
footerConfig: { text: '', links: [] },
settings: {},
publishedVersion: null,
draftUpdatedAt: '2026-04-23T00:00:00.000Z',
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
};
}
function localPage(overrides: Partial<LocalWebsitePage> = {}): LocalWebsitePage {
return {
id: PAGE_ID,
siteId: SITE_ID,
path: '/',
title: 'Start',
seo: {},
order: 1024,
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
...overrides,
};
}
function localBlock(
id: string,
order: number,
overrides: Partial<LocalWebsiteBlock> = {}
): LocalWebsiteBlock {
return {
id,
pageId: PAGE_ID,
parentBlockId: null,
slotKey: null,
type: 'richText',
props: { content: `Block ${id}`, align: 'left', size: 'md' },
schemaVersion: 1,
order,
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
...overrides,
};
}
describe('buildSnapshot — determinism', () => {
beforeEach(async () => {
await websitesTable.clear();
await websitePagesTable.clear();
await websiteBlocksTable.clear();
});
it('two calls against the same draft produce byte-identical JSON', async () => {
await websitesTable.add(localSite());
await websitePagesTable.add(localPage());
await websiteBlocksTable.bulkAdd([
localBlock('b-gamma', 3072),
localBlock('b-alpha', 1024),
localBlock('b-beta', 2048),
]);
const first = await buildSnapshot(SITE_ID);
const second = await buildSnapshot(SITE_ID);
expect(JSON.stringify(first)).toBe(JSON.stringify(second));
});
it('sorts blocks with equal order by id (stable tiebreaker)', async () => {
await websitesTable.add(localSite());
await websitePagesTable.add(localPage());
await websiteBlocksTable.bulkAdd([
localBlock('b-zzzzz', 1024),
localBlock('b-aaaaa', 1024),
localBlock('b-mmmmm', 1024),
]);
const snap = await buildSnapshot(SITE_ID);
const ids = snap.pages[0]!.blocks.map((b) => b.id);
expect(ids).toEqual(['b-aaaaa', 'b-mmmmm', 'b-zzzzz']);
});
});
describe('buildBlockTree — orphan handling', () => {
it('drops blocks whose parentBlockId does not resolve', () => {
const blocks: LocalWebsiteBlock[] = [
localBlock('b-root', 1024),
localBlock('b-orphan', 2048, { parentBlockId: 'b-does-not-exist' }),
localBlock('b-child', 3072, { parentBlockId: 'b-root' }),
];
const tree = buildBlockTree(blocks);
// Top-level contains only b-root.
expect(tree.map((t) => t.id)).toEqual(['b-root']);
// b-root has exactly one child (b-child) — the orphan is dropped.
expect(tree[0]!.children.map((c) => c.id)).toEqual(['b-child']);
});
it('preserves nested children ordered by (order, id)', () => {
const blocks: LocalWebsiteBlock[] = [
localBlock('parent', 1024),
localBlock('child-c', 2048, { parentBlockId: 'parent' }),
localBlock('child-a', 1024, { parentBlockId: 'parent' }),
localBlock('child-b', 1024, { parentBlockId: 'parent' }),
];
const tree = buildBlockTree(blocks);
expect(tree[0]!.children.map((c) => c.id)).toEqual(['child-a', 'child-b', 'child-c']);
});
});

View file

@ -0,0 +1,84 @@
# Website Builder — Observability
_Shipped 2026-04-23 (M7)._
Every metric below lives on mana-api's `/metrics` scrape endpoint (port 3060, unauthenticated — relies on reverse-proxy to keep it off the public internet).
## Metrics
### Write path
| Metric | Type | Labels | What it tells you |
|---|---|---|---|
| `mana_api_website_publish_total` | Counter | `result` = success \| slug_taken \| invalid \| error | Publish-attempt outcome mix. |
| `mana_api_website_publish_duration_seconds` | Histogram | — | End-to-end publish latency (validation + transaction). |
| `mana_api_website_domain_verify_total` | Counter | `result` = verified \| failed | Custom-domain DNS check outcomes. |
### Public surface
| Metric | Type | Labels | What it tells you |
|---|---|---|---|
| `mana_api_website_public_reads_total` | Counter | `result` = hit \| not_found | Anonymous reads of `/public/sites/:slug`. |
| `mana_api_website_public_read_age_seconds` | Histogram | — | Age of the served snapshot at read time. A bimodal distribution (many <10s AND many >1h) tells you the edge cache is working. |
| `mana_api_website_host_resolve_total` | Counter | `result` = hit \| miss \| error | Custom-host → slug resolutions from the SvelteKit hook. |
| `mana_api_website_submissions_total` | Counter | `result` = received \| spam \| rate_limit \| not_found \| invalid | Form submissions received. |
## Quick PromQL queries
**Publish success rate (30 min rolling):**
```promql
sum(rate(mana_api_website_publish_total{result="success"}[30m]))
/
sum(rate(mana_api_website_publish_total[30m]))
```
**p95 publish latency:**
```promql
histogram_quantile(0.95, sum by (le) (rate(mana_api_website_publish_duration_seconds_bucket[10m])))
```
**Custom-host resolve hit rate (production target: >98% once bindings stabilise):**
```promql
sum(rate(mana_api_website_host_resolve_total{result="hit"}[5m]))
/
sum(rate(mana_api_website_host_resolve_total[5m]))
```
**Spam-to-received ratio (form submissions):**
```promql
sum(rate(mana_api_website_submissions_total{result="spam"}[1h]))
/
sum(rate(mana_api_website_submissions_total{result=~"received|spam"}[1h]))
```
## Alerts (recommended)
- **`website-publish-failure-spike`** — fires when `rate(mana_api_website_publish_total{result="error"}[10m]) > 0.1/s`. Indicates DB trouble or an unhandled exception path.
- **`website-public-cold`** — fires when `rate(mana_api_website_public_reads_total[1h]) > 10/s AND rate(mana_api_website_public_read_age_seconds_count{le="10"}[1h]) / rate(mana_api_website_public_read_age_seconds_count[1h]) > 0.5`. Half the traffic is hitting fresh snapshots = the edge cache isn't doing its job, usually a CF config drift.
- **`website-domain-verify-failed-burst`** — fires when `increase(mana_api_website_domain_verify_total{result="failed"}[1h]) > 20`. Either ops broke the DNS target (CNAME not pointing anywhere) or one angry user is thrashing.
- **`website-form-spam-storm`** — fires when `rate(mana_api_website_submissions_total{result="spam"}[5m]) > 1/s`. Honeypot is holding, but a motivated attacker might move on to CAPTCHA-busting next.
## Dashboard
Grafana dashboard lives at `grafana.internal/d/website-builder` (add it to the existing "Mana Services" folder). Panels: publish volume + outcome mix, publish latency heatmap, submissions/spam split, host-resolve hit ratio, domain-verify trend.
## Orphan-asset GC
Read-only scan script at `apps/api/scripts/gc-website-assets.ts`. Run manually for now:
```bash
MANA_SERVICE_KEY=… DATABASE_URL=… bun apps/api/scripts/gc-website-assets.ts
```
The script:
1. Walks every `published_snapshots.blob` and `submissions.payload` to collect referenced `mediaId`s.
2. Asks mana-media for everything scoped to `app=website`.
3. Reports items older than 30 days that aren't referenced anywhere.
**Current status: report-only.** No deletion. After 23 weeks of production reports showing the candidate list is stable and doesn't include false positives, we flip a `--delete` flag in a follow-up commit.
## Future (M7.x)
- Per-site view counts. Would require a cheap counter table (`website.site_views { site_id, day, count }`) incremented from the public-read handler. Skipped in M7 first-pass because the analytics block already covers the per-visit needs; add when someone asks for a dashboard inside the editor.
- Cloudflare hostname status reconciliation. Once the CF SaaS API is wired, a periodic poller should compare our `custom_domains.status` against CF's `hostname.ssl.status` and flag drift.
- Submission-payload retention job. Fields are kept indefinitely today; when target-delivery lands (M4.x) the job runs after delivery and nulls the payload, keeping only IDs + status.

View file

@ -736,14 +736,16 @@ Der Service bleibt vorerst nebenher. Gründe:
### M7 — Observability, GC, Analytics ### M7 — Observability, GC, Analytics
- [ ] Prometheus-Metrics: `website_publish_total`, `website_submissions_total`, `website_render_duration_seconds`, `website_cache_hit_ratio` - [x] Prometheus-Metrics: `mana_api_website_publish_total{result}`, `_publish_duration_seconds`, `_submissions_total{result}`, `_host_resolve_total{result}`, `_domain_verify_total{result}`, `_public_reads_total{result}`, `_public_read_age_seconds`. `/metrics`-Endpoint an apps/api root (unauth, verlässt sich auf Reverse-Proxy).
- [ ] Orphan-Asset-GC: Job findet uload-Assets, die in keinem Block mehr referenziert sind, löscht nach 30d Grace-Period - [x] Orphan-Asset-GC: Script `apps/api/scripts/gc-website-assets.ts`**read-only** in M7 first-pass. Walks published_snapshots + submissions for mediaId refs, compares against mana-media's app=website listing, reports orphans older than 30d. Deletion-Toggle nach 2-3 Wochen stabiler Reports.
- [ ] `analytics`-Block-Typ: Plausible/Simple Analytics Snippet als Opt-In - [x] `analytics`-Block-Typ: Plausible + Umami mit self-hosted Script-URL-Override. Unsichtbar im Editor, emittiert genau einen `<script>` im public-Mode. Keine Cookies, keine PII.
- [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten) - [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten) — verschoben auf M7.x. Der Analytics-Block deckt die Visitor-Seite; Editor-Inline-Dashboard ist Backlog bis jemand konkret danach fragt.
- [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen (behält nur IDs + Status für Audit) - [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen — M4.x Voraussetzung (Target-Delivery ist noch nicht gewired).
- [ ] Dashboards in `docs/observability/website.md` - [x] Dashboards in `docs/observability/website.md` — Metrics-Referenz, PromQL-Queries, Alert-Vorschläge, Grafana-Pfad.
- [x] (Bonus) Rollback-UI — Version-History-Dialog in PublishBar, M2-Exit-Kriterium „Rollback funktioniert" jetzt auch im Editor abgehakt.
- [x] (Bonus) Snapshot-Determinism-Test — vitest in `publish.test.ts` verifiziert byte-identische Outputs + Orphan-Drop.
**Exit criteria:** Betrieb ist beobachtbar, Storage wächst nicht unbegrenzt. **Exit criteria:** Betrieb ist beobachtbar (Metrics + PromQL), Storage-Wachstum ist überwacht (Scan-Script), Analytics-Opt-In steht.
## Risiken + Mitigation ## Risiken + Mitigation
@ -788,4 +790,5 @@ Der Service bleibt vorerst nebenher. Gründe:
| M3 | 5 more blocks, containers, upload, themes | `7a4f8894e` | | M3 | 5 more blocks, containers, upload, themes | `7a4f8894e` |
| M4 | Forms + moduleEmbed | `57be0f61b` | | M4 | Forms + moduleEmbed | `57be0f61b` |
| M5 | AI tools + starter templates | `13efae8cd` | | M5 | AI tools + starter templates | `13efae8cd` |
| M6 | Subdomain + custom-domain + tier gate + DNS verify + hooks-rewrite | (pending commit at end of M6 session) | | M6 | Subdomain + custom-domain + tier gate + DNS verify + hooks-rewrite | `3eca5ac20` |
| M7 | Observability (Prom metrics, /metrics endpoint) + analytics block + orphan-asset GC script + rollback UI + determinism test + docs | (pending commit at end of M7 session) |

View file

@ -75,6 +75,12 @@ const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="n
// Context icon (document/knowledge with sky blue gradient) // Context icon (document/knowledge with sky blue gradient)
const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`; const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`;
// Wardrobe icon — T-shirt on hanger with rose-violet gradient.
// Rose/violet to sit between Picture (green) and Calc (pink) without
// clashing; the hanger loop sits on the shoulder line so the silhouette
// reads as "clothing" at any scale.
const wardrobeSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#wardrobeGrad)"/><path d="M512 246c-34 0-62 28-62 62 0 15 6 28 15 37l-113 58c-14 7-22 22-22 37v40l-40 28c-10 7-13 20-8 31l20 40c5 10 16 16 27 13l46-12v256c0 18 14 32 32 32h250c18 0 32-14 32-32V580l46 12c11 3 22-3 27-13l20-40c5-11 2-24-8-31l-40-28v-40c0-15-8-30-22-37l-113-58c9-9 15-22 15-37 0-34-28-62-62-62zm0 44c18 0 32 14 32 32s-14 32-32 32-32-14-32-32 14-32 32-32z" fill="white"/><path d="M420 450c0 50 41 90 92 90s92-40 92-90" stroke="#be185d" stroke-width="6" stroke-linecap="round" fill="none" stroke-opacity="0.25"/><defs><linearGradient id="wardrobeGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#e11d48"/><stop offset="1" stop-color="#a21caf"/></linearGradient></defs></svg>`;
/** /**
* App icons as data URLs * App icons as data URLs
* Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image * Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image
@ -102,6 +108,7 @@ export const APP_ICONS = {
todo: svgToDataUrl(todoSvg), todo: svgToDataUrl(todoSvg),
mail: svgToDataUrl(mailSvg), mail: svgToDataUrl(mailSvg),
inventory: svgToDataUrl(inventorySvg), inventory: svgToDataUrl(inventorySvg),
wardrobe: svgToDataUrl(wardrobeSvg),
questions: svgToDataUrl(questionsSvg), questions: svgToDataUrl(questionsSvg),
context: svgToDataUrl(contextSvg), context: svgToDataUrl(contextSvg),
citycorners: svgToDataUrl(citycornersSvg), citycorners: svgToDataUrl(citycornersSvg),

View file

@ -377,6 +377,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta', status: 'beta',
requiredTier: 'guest', requiredTier: 'guest',
}, },
{
id: 'wardrobe',
name: 'Wardrobe',
description: {
de: 'Dein digitaler Kleiderschrank',
en: 'Your digital wardrobe',
},
longDescription: {
de: 'Fotografiere Kleidungsstücke, komponiere Outfits und probiere sie mit KI an dir selbst an — vom eigenen Schrank bis zu Brillen, Vereinstrikots und Merch.',
en: 'Photograph garments, compose outfits, and try them on yourself with AI — from your own closet to glasses, club jerseys, and brand merch.',
},
icon: APP_ICONS.wardrobe,
color: '#e11d48',
comingSoon: false,
status: 'beta',
requiredTier: 'beta',
},
{ {
id: 'questions', id: 'questions',
name: 'Questions', name: 'Questions',

View file

@ -88,6 +88,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'invoices', 'invoices',
'activity', 'activity',
'goals', 'goals',
'wardrobe', // Merch-Katalog (T-Shirts, Caps, Zip-Hoodies)
], ],
club: [ club: [
@ -115,6 +116,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'photos', 'photos',
'activity', 'activity',
'goals', 'goals',
'wardrobe', // Vereinstrikots, Club-Bekleidung
], ],
family: [ family: [
@ -139,6 +141,8 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'wetter', 'wetter',
'wisekeep', 'wisekeep',
'firsts', 'firsts',
'wardrobe', // Familien-Kleiderschrank (Kinder inkl.); Try-On
// rendert auf dem aufrufenden Elternteil, nicht auf Kindern
], ],
team: [ team: [
@ -164,6 +168,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'times', 'times',
'activity', 'activity',
'goals', 'goals',
'wardrobe', // Bühnen-Kostüme, Uniformen, Produktions-Wardrobe
], ],
practice: [ practice: [
@ -185,6 +190,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'research-lab', 'research-lab',
'activity', 'activity',
'goals', 'goals',
'wardrobe', // Praxis-Kittel, Dresscode-Items
], ],
} as const; } as const;

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { AnalyticsProps } from './schema';
let { block, mode }: BlockRenderProps<AnalyticsProps> = $props();
const isPublic = $derived(mode === 'public');
const configured = $derived(Boolean(block.props.siteKey));
const plausibleSrc = $derived.by(() => {
if (block.props.scriptUrl) return block.props.scriptUrl;
return 'https://plausible.io/js/script.js';
});
const umamiSrc = $derived.by(() => {
if (block.props.scriptUrl) return block.props.scriptUrl;
return 'https://cloud.umami.is/script.js';
});
</script>
{#if !isPublic}
<!-- Silent placeholder in edit/preview — the block is invisible and
exists only to emit a tracker snippet at publish time. -->
<div class="wb-analytics-meta" data-mode={mode} aria-hidden="true">
<span class="wb-analytics-meta__pill">
📊 Analytics: {block.props.provider}
{#if configured}({block.props.siteKey}){/if}
</span>
</div>
{:else if configured}
{#if block.props.provider === 'plausible'}
<script defer data-domain={block.props.siteKey} src={plausibleSrc}></script>
{:else if block.props.provider === 'umami'}
<script defer data-website-id={block.props.siteKey} src={umamiSrc}></script>
{/if}
{/if}
<style>
.wb-analytics-meta {
display: flex;
justify-content: center;
padding: 0.5rem;
}
.wb-analytics-meta__pill {
padding: 0.25rem 0.75rem;
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 9999px;
opacity: 0.6;
font-family: ui-monospace, monospace;
}
</style>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { AnalyticsProps } from './schema';
let { block, onChange }: BlockInspectorProps<AnalyticsProps> = $props();
const provider = $derived(block.props.provider);
const helpText = $derived.by(() => {
if (provider === 'plausible') {
return 'Trage hier die Domain ein, die du bei Plausible registriert hast (z.B. "meineseite.de"). Keine Cookies, DSGVO-konform.';
}
return 'Umami Website-ID (UUID). Keine Cookies, DSGVO-konform.';
});
const keyLabel = $derived(provider === 'plausible' ? 'Domain' : 'Website-ID');
const keyPlaceholder = $derived(provider === 'plausible' ? 'meineseite.de' : 'abc12345-1234-…');
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Provider</span>
<select
value={block.props.provider}
onchange={(e) => onChange({ provider: e.currentTarget.value as AnalyticsProps['provider'] })}
>
<option value="plausible">Plausible</option>
<option value="umami">Umami</option>
</select>
</label>
<label class="wb-field">
<span>{keyLabel}</span>
<input
type="text"
value={block.props.siteKey}
oninput={(e) => onChange({ siteKey: e.currentTarget.value.trim() })}
placeholder={keyPlaceholder}
/>
<small>{helpText}</small>
</label>
<label class="wb-field">
<span>Script-URL (optional)</span>
<input
type="url"
value={block.props.scriptUrl}
oninput={(e) => onChange({ scriptUrl: e.currentTarget.value.trim() })}
placeholder="https://analytics.deineseite.de/script.js"
/>
<small>Für selbst-gehostete Instanzen. Leer lassen für Default-CDN.</small>
</label>
<p class="wb-hint">
Der Block ist im Editor unsichtbar — er fügt auf der veröffentlichten Website einen einzigen
&lt;script&gt;-Tag ein. Keine Cookies, keine PII.
</p>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-field input,
.wb-field select {
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 0.875rem;
}
.wb-field small {
font-size: 0.7rem;
opacity: 0.55;
line-height: 1.4;
}
.wb-hint {
margin: 0;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 0.375rem;
font-size: 0.75rem;
opacity: 0.6;
line-height: 1.4;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Analytics from './Analytics.svelte';
import AnalyticsInspector from './AnalyticsInspector.svelte';
import { AnalyticsSchema, ANALYTICS_DEFAULTS, type AnalyticsProps } from './schema';
export const analyticsBlockSpec: BlockSpec<AnalyticsProps> = {
type: 'analytics',
label: 'Analytics',
icon: 'chart',
category: 'embed',
schema: AnalyticsSchema,
schemaVersion: 1,
defaults: ANALYTICS_DEFAULTS,
Component: Analytics,
Inspector: AnalyticsInspector,
};
export type { AnalyticsProps };
export { AnalyticsSchema, ANALYTICS_DEFAULTS };

View file

@ -0,0 +1,29 @@
import { z } from 'zod';
/**
* Analytics block injects a tracking snippet into the published
* page. Opt-in, no cookies by design (Plausible / Umami are
* cookieless).
*
* The block renders nothing visible in edit/preview; in public mode
* it emits a single <script> tag. No PII collection (no visitor IDs,
* no fingerprinting), no admin UI access required.
*/
export const AnalyticsSchema = z.object({
provider: z.enum(['plausible', 'umami']).default('plausible'),
/** Plausible: the domain property; Umami: the website id (UUID). */
siteKey: z.string().max(128).default(''),
/**
* Optional script-host override for self-hosted instances. Leave
* empty for the default provider CDN. Validated as full https URL.
*/
scriptUrl: z.string().max(512).default(''),
});
export type AnalyticsProps = z.infer<typeof AnalyticsSchema>;
export const ANALYTICS_DEFAULTS: AnalyticsProps = {
provider: 'plausible',
siteKey: '',
scriptUrl: '',
};

View file

@ -52,6 +52,12 @@ export {
type EmbedItem, type EmbedItem,
type EmbedSource, type EmbedSource,
} from './moduleEmbed'; } from './moduleEmbed';
export {
analyticsBlockSpec,
AnalyticsSchema,
ANALYTICS_DEFAULTS,
type AnalyticsProps,
} from './analytics';
export { export {
THEME_PRESETS, THEME_PRESETS,

View file

@ -9,6 +9,7 @@ import { columnsBlockSpec } from './columns';
import { galleryBlockSpec } from './gallery'; import { galleryBlockSpec } from './gallery';
import { formBlockSpec } from './form'; import { formBlockSpec } from './form';
import { moduleEmbedBlockSpec } from './moduleEmbed'; import { moduleEmbedBlockSpec } from './moduleEmbed';
import { analyticsBlockSpec } from './analytics';
/** /**
* The block registry single source of truth for every block type the * The block registry single source of truth for every block type the
@ -27,6 +28,7 @@ export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
faqBlockSpec, faqBlockSpec,
formBlockSpec, formBlockSpec,
moduleEmbedBlockSpec, moduleEmbedBlockSpec,
analyticsBlockSpec,
columnsBlockSpec, columnsBlockSpec,
spacerBlockSpec, spacerBlockSpec,
] as unknown as readonly BlockSpec<unknown>[]; ] as unknown as readonly BlockSpec<unknown>[];

355
pnpm-lock.yaml generated
View file

@ -99,6 +99,9 @@ importers:
postgres: postgres:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.9 version: 3.4.9
prom-client:
specifier: ^15.1.3
version: 15.1.3
rrule: rrule:
specifier: ^2.8.1 specifier: ^2.8.1
version: 2.8.1 version: 2.8.1
@ -138,14 +141,14 @@ importers:
version: link:../../../../packages/shared-landing-ui version: link:../../../../packages/shared-landing-ui
astro: astro:
specifier: ^5.16.0 specifier: ^5.16.0
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript: typescript:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.3 version: 5.9.3
devDependencies: devDependencies:
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.18 specifier: ^0.5.18
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
@ -154,13 +157,13 @@ importers:
version: 20.19.39 version: 20.19.39
eslint: eslint:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1) version: 9.39.4(jiti@1.21.7)
eslint-config-prettier: eslint-config-prettier:
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-astro: eslint-plugin-astro:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.6.0(eslint@9.39.4(jiti@2.6.1)) version: 1.6.0(eslint@9.39.4(jiti@1.21.7))
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.8.1 version: 3.8.1
@ -253,10 +256,10 @@ importers:
version: 3.7.2 version: 3.7.2
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
astro: astro:
specifier: ^5.16.11 specifier: ^5.16.11
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
tailwindcss: tailwindcss:
specifier: ^3.4.17 specifier: ^3.4.17
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
@ -2876,6 +2879,9 @@ importers:
jose: jose:
specifier: ^6.1.2 specifier: ^6.1.2
version: 6.2.2 version: 6.2.2
prom-client:
specifier: ^15.1.3
version: 15.1.3
zod: zod:
specifier: ^3.25.76 specifier: ^3.25.76
version: 3.25.76 version: 3.25.76
@ -17247,6 +17253,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -17267,16 +17283,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -19436,6 +19442,11 @@ snapshots:
'@esbuild/win32-x64@0.27.7': '@esbuild/win32-x64@0.27.7':
optional: true optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -24398,6 +24409,108 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies: dependencies:
'@astrojs/compiler': 2.13.1 '@astrojs/compiler': 2.13.1
@ -24602,108 +24715,6 @@ snapshots:
- uploadthing - uploadthing
- yaml - yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies: dependencies:
'@astrojs/compiler': 2.13.1 '@astrojs/compiler': 2.13.1
@ -26535,6 +26546,11 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
semver: 7.7.4 semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -26544,6 +26560,10 @@ snapshots:
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -26588,6 +26608,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.58.0
astro-eslint-parser: 1.4.0
eslint: 9.39.4(jiti@1.21.7)
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
globals: 16.5.0
postcss: 8.5.8
postcss-selector-parser: 7.1.1
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -26761,6 +26795,47 @@ snapshots:
eslint-visitor-keys@5.0.1: {} eslint-visitor-keys@5.0.1: {}
eslint@9.39.4(jiti@1.21.7):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 1.21.7
transitivePeerDependencies:
- supports-color
eslint@9.39.4(jiti@2.6.1): eslint@9.39.4(jiti@2.6.1):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -33868,6 +33943,23 @@ snapshots:
lightningcss: 1.32.0 lightningcss: 1.32.0
terser: 5.46.1 terser: 5.46.1
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 20.19.39
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@ -33902,23 +33994,6 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@ -33936,6 +34011,10 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -33944,10 +34023,6 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)