From 26de6b4358e46880137ba9e1946a7f6e722907de Mon Sep 17 00:00:00 2001 From: Andrea Baccega Date: Tue, 7 Jul 2026 22:12:17 +0200 Subject: [PATCH 1/4] refactor(orders): extract order domain into backend/src/orders and web/src/orders modules --- backend/package.json | 2 +- backend/src/orders/config.ts | 20 ++ backend/src/orders/create-order.ts | 163 ++++++++++++ backend/src/orders/index.ts | 4 + backend/src/orders/read-models.ts | 130 +++++++++ backend/src/orders/status.ts | 44 ++++ backend/src/routes/admin-checks.ts | 2 +- backend/src/routes/admin-orders.ts | 116 +------- backend/src/routes/orders.ts | 249 +----------------- backend/src/routes/staff.ts | 110 +------- .../app/[locale]/menu/MenuPageClient.test.tsx | 5 +- .../selection/SelectionPageClient.test.tsx | 12 +- .../components/admin/pages/EditEntryPage.tsx | 3 +- .../admin/pages/KitchenBoardPage.test.tsx | 2 +- .../admin/pages/KitchenBoardPage.tsx | 2 +- web/src/components/menu/SelectionContent.tsx | 3 +- .../components/staff/OrderReviewPage.test.tsx | 7 +- web/src/components/staff/OrderReviewPage.tsx | 3 +- web/src/lib/api.ts | 110 +------- web/src/orders/api.ts | 106 ++++++++ 20 files changed, 523 insertions(+), 570 deletions(-) create mode 100644 backend/src/orders/config.ts create mode 100644 backend/src/orders/create-order.ts create mode 100644 backend/src/orders/index.ts create mode 100644 backend/src/orders/read-models.ts create mode 100644 backend/src/orders/status.ts create mode 100644 web/src/orders/api.ts diff --git a/backend/package.json b/backend/package.json index dacac58..46e98c5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "wrangler dev --persist-to .wrangler/state", - "deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)}", + "deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)} --var ORDER_TIME_ZONE:${ORDER_TIME_ZONE:-$(node -e \"console.log(require('../.tony-menu.local.json').backend.orderTimeZone || 'UTC')\")}", "check": "tsc --noEmit", "typegen": "wrangler types", "import:backup": "tsx scripts/import-from-backup.ts", diff --git a/backend/src/orders/config.ts b/backend/src/orders/config.ts new file mode 100644 index 0000000..f1cc3c9 --- /dev/null +++ b/backend/src/orders/config.ts @@ -0,0 +1,20 @@ +import { eq } from 'drizzle-orm'; +import { normalizeModulesConfig } from '@menu/schemas'; +import * as schema from '../db/schema'; +import type { DbInstance } from '../db'; + +/** Loads the normalized ordering config; null when unpublished or missing. */ +export async function loadOrdering(db: DbInstance) { + const [settingsRow] = await db + .select({ + modules: schema.settings.modules, + aiChatEnabled: schema.settings.aiChatEnabled, + aiVoiceEnabled: schema.settings.aiVoiceEnabled, + publicationState: schema.settings.publicationState, + }) + .from(schema.settings) + .where(eq(schema.settings.id, 1)) + .limit(1); + if (!settingsRow || settingsRow.publicationState !== 'published') return null; + return normalizeModulesConfig(settingsRow.modules, settingsRow).ordering; +} diff --git a/backend/src/orders/create-order.ts b/backend/src/orders/create-order.ts new file mode 100644 index 0000000..5ad15b3 --- /dev/null +++ b/backend/src/orders/create-order.ts @@ -0,0 +1,163 @@ +import { and, count, eq, inArray, isNull } from 'drizzle-orm'; +import * as schema from '../db/schema'; +import type { DbInstance } from '../db'; + +/** YYYYMMDD integer bucket in the restaurant timezone. */ +export function currentOrderDay(now = new Date(), timeZone = 'UTC'): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const value = (type: string) => Number(parts.find((part) => part.type === type)!.value); + return value('year') * 10000 + value('month') * 100 + value('day'); +} + +/** Intents expire 30 minutes after creation (#19). */ +export const INTENT_TTL_MS = 30 * 60_000; + +const MAX_NUMBERING_RETRIES = 3; + +export type CreateOrderResult = + | { ok: true; orderId: string; dailyNumber: number } + | { error: 'stale_items'; staleEntryIds: string[] } + | { error: 'invalid_table_session' } + | { error: 'check_open' }; + +/** Shared order creation path for diner, staff, and admin submits. */ +export async function createOrder( + db: DbInstance, + idempotencyKey: string, + rawLines: { entryId: string; quantity: number }[], + tableSessionId?: string | null, + actor: 'diner' | 'staff' | 'admin' = tableSessionId ? 'staff' : 'diner', + actorName?: string | null, + orderTimeZone = 'UTC', +): Promise { + const existing = await findByIdempotencyKey(db, idempotencyKey); + if (existing) return existing; + + if (tableSessionId != null) { + const [session] = await db + .select({ id: schema.tableSessions.id }) + .from(schema.tableSessions) + .where(and(eq(schema.tableSessions.id, tableSessionId), isNull(schema.tableSessions.closedAt))) + .limit(1); + if (!session) return { error: 'invalid_table_session' }; + + const [openCheck] = await db + .select({ id: schema.checks.id }) + .from(schema.checks) + .where(and(eq(schema.checks.tableSessionId, tableSessionId), eq(schema.checks.status, 'open'))) + .limit(1); + if (openCheck) return { error: 'check_open' }; + } + + const quantities = new Map(); + for (const line of rawLines) { + quantities.set(line.entryId, (quantities.get(line.entryId) ?? 0) + line.quantity); + } + const entryIds = [...quantities.keys()]; + + const entries = await db + .select({ + id: schema.menuEntries.id, + name: schema.menuEntries.name, + price: schema.menuEntries.price, + hidden: schema.menuEntries.hidden, + outOfStock: schema.menuEntries.outOfStock, + }) + .from(schema.menuEntries) + .where(inArray(schema.menuEntries.id, entryIds)); + const entryById = new Map(entries.map((e) => [e.id, e])); + const staleEntryIds = entryIds.filter((id) => { + const entry = entryById.get(id); + return !entry || entry.hidden || entry.outOfStock; + }); + if (staleEntryIds.length > 0) return { error: 'stale_items', staleEntryIds }; + + const destinationRows = await db + .select({ + entryId: schema.entryDestinations.entryId, + destinationId: schema.orderDestinations.id, + destinationName: schema.orderDestinations.name, + }) + .from(schema.entryDestinations) + .innerJoin(schema.orderDestinations, eq(schema.entryDestinations.destinationId, schema.orderDestinations.id)) + .where(inArray(schema.entryDestinations.entryId, entryIds)); + const destinationsByEntry = new Map(); + for (const row of destinationRows) { + const list = destinationsByEntry.get(row.entryId) ?? []; + list.push({ destinationId: row.destinationId, destinationName: row.destinationName }); + destinationsByEntry.set(row.entryId, list); + } + + const orderId = crypto.randomUUID(); + const orderDay = currentOrderDay(new Date(), orderTimeZone); + const itemValues = entryIds.map((entryId) => { + const entry = entryById.get(entryId)!; + return { + id: crypto.randomUUID(), + orderId, + entryId, + name: entry.name, + price: entry.price, + quantity: quantities.get(entryId)!, + }; + }); + const itemDestinationValues = itemValues.flatMap((item) => + (destinationsByEntry.get(item.entryId) ?? []).map((dest) => ({ + id: crypto.randomUUID(), + orderItemId: item.id, + destinationId: dest.destinationId, + destinationName: dest.destinationName, + })), + ); + + for (let attempt = 0; attempt < MAX_NUMBERING_RETRIES; attempt++) { + const [{ value: existingCount }] = await db + .select({ value: count() }) + .from(schema.orders) + .where(eq(schema.orders.orderDay, orderDay)); + const dailyNumber = existingCount + 1; + + try { + await db.batch([ + db.insert(schema.orders).values({ + id: orderId, + orderDay, + dailyNumber, + idempotencyKey, + tableSessionId: tableSessionId ?? null, + }), + db.insert(schema.orderItems).values(itemValues), + ...(itemDestinationValues.length > 0 ? [db.insert(schema.orderItemDestinations).values(itemDestinationValues)] : []), + db.insert(schema.orderEvents).values({ + id: crypto.randomUUID(), + orderId, + status: 'submitted', + actor, + actorName: actorName ?? null, + }), + ]); + return { ok: true, orderId, dailyNumber }; + } catch (error) { + const won = await findByIdempotencyKey(db, idempotencyKey); + if (won) return won; + if (attempt === MAX_NUMBERING_RETRIES - 1) throw error; + } + } + + throw new Error('Could not assign order number'); +} + +async function findByIdempotencyKey(db: DbInstance, idempotencyKey: string) { + const [row] = await db + .select({ id: schema.orders.id, dailyNumber: schema.orders.dailyNumber }) + .from(schema.orders) + .where(eq(schema.orders.idempotencyKey, idempotencyKey)) + .limit(1); + if (!row) return null; + return { ok: true as const, orderId: row.id, dailyNumber: row.dailyNumber }; +} diff --git a/backend/src/orders/index.ts b/backend/src/orders/index.ts new file mode 100644 index 0000000..5afaaf6 --- /dev/null +++ b/backend/src/orders/index.ts @@ -0,0 +1,4 @@ +export * from './config'; +export * from './create-order'; +export * from './read-models'; +export * from './status'; diff --git a/backend/src/orders/read-models.ts b/backend/src/orders/read-models.ts new file mode 100644 index 0000000..aa53d00 --- /dev/null +++ b/backend/src/orders/read-models.ts @@ -0,0 +1,130 @@ +import { asc, desc, eq, inArray } from 'drizzle-orm'; +import * as schema from '../db/schema'; +import type { DbInstance } from '../db'; + +export async function buildAdminOrdersForDay(db: DbInstance, day: number) { + const orderRows = await db + .select({ + id: schema.orders.id, + dailyNumber: schema.orders.dailyNumber, + status: schema.orders.status, + rejectReason: schema.orders.rejectReason, + createdAt: schema.orders.createdAt, + tableName: schema.tables.name, + areaName: schema.areas.name, + updatedAt: schema.orders.updatedAt, + }) + .from(schema.orders) + .leftJoin(schema.tableSessions, eq(schema.orders.tableSessionId, schema.tableSessions.id)) + .leftJoin(schema.tables, eq(schema.tableSessions.tableId, schema.tables.id)) + .leftJoin(schema.areas, eq(schema.tables.areaId, schema.areas.id)) + .where(eq(schema.orders.orderDay, day)) + .orderBy(desc(schema.orders.dailyNumber)); + + const orderIds = orderRows.map((o) => o.id); + const itemRows = orderIds.length > 0 ? await db.select().from(schema.orderItems).where(inArray(schema.orderItems.orderId, orderIds)) : []; + const itemIds = itemRows.map((i) => i.id); + const destRows = itemIds.length > 0 ? await db.select().from(schema.orderItemDestinations).where(inArray(schema.orderItemDestinations.orderItemId, itemIds)) : []; + const eventRows = orderIds.length > 0 ? await db.select().from(schema.orderEvents).where(inArray(schema.orderEvents.orderId, orderIds)) : []; + + const submittedBy = new Map(); + for (const e of eventRows) if (e.status === 'submitted' && e.actorName) submittedBy.set(e.orderId, e.actorName); + + const destsByItem = new Map(); + for (const d of destRows) { + const list = destsByItem.get(d.orderItemId) ?? []; + list.push(d); + destsByItem.set(d.orderItemId, list); + } + const itemsByOrder = new Map(); + for (const i of itemRows) { + const list = itemsByOrder.get(i.orderId) ?? []; + list.push(i); + itemsByOrder.set(i.orderId, list); + } + + return orderRows.map((o) => ({ + id: o.id, + dailyNumber: o.dailyNumber, + status: o.status, + rejectReason: o.rejectReason, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + tableName: o.tableName ? (o.areaName ? `${o.areaName} · ${o.tableName}` : o.tableName) : null, + submittedBy: submittedBy.get(o.id) ?? null, + items: (itemsByOrder.get(o.id) ?? []).map((i) => ({ + id: i.id, + name: i.name, + price: i.price, + quantity: i.quantity, + destinations: (destsByItem.get(i.id) ?? []).map((d) => ({ + id: d.id, + destinationId: d.destinationId, + destinationName: d.destinationName, + printedAt: d.printedAt, + })), + })), + })); +} + +export async function buildStaffSessionDetail(db: DbInstance, sessionId: string) { + const [session] = await db + .select({ + id: schema.tableSessions.id, + openedAt: schema.tableSessions.openedAt, + tableId: schema.tables.id, + tableName: schema.tables.name, + areaName: schema.areas.name, + }) + .from(schema.tableSessions) + .innerJoin(schema.tables, eq(schema.tableSessions.tableId, schema.tables.id)) + .leftJoin(schema.areas, eq(schema.tables.areaId, schema.areas.id)) + .where(eq(schema.tableSessions.id, sessionId)) + .limit(1); + if (!session) return null; + + const orderRows = await db.select().from(schema.orders).where(eq(schema.orders.tableSessionId, sessionId)).orderBy(asc(schema.orders.createdAt)); + const orderIds = orderRows.map((o) => o.id); + const itemRows = orderIds.length > 0 ? await db.select().from(schema.orderItems).where(inArray(schema.orderItems.orderId, orderIds)) : []; + const eventRows = orderIds.length > 0 + ? await db.select().from(schema.orderEvents).where(inArray(schema.orderEvents.orderId, orderIds)).orderBy(asc(schema.orderEvents.createdAt)) + : []; + + const eventsByOrder = new Map(); + for (const e of eventRows) { + const list = eventsByOrder.get(e.orderId) ?? []; + list.push(e); + eventsByOrder.set(e.orderId, list); + } + const itemsByOrder = new Map(); + for (const i of itemRows) { + const list = itemsByOrder.get(i.orderId) ?? []; + list.push(i); + itemsByOrder.set(i.orderId, list); + } + + return { + sessionId: session.id, + tableId: session.tableId, + tableName: session.areaName ? `${session.areaName} · ${session.tableName}` : session.tableName, + openedAt: session.openedAt, + orders: orderRows.map((o) => ({ + id: o.id, + dailyNumber: o.dailyNumber, + status: o.status, + createdAt: o.createdAt, + items: (itemsByOrder.get(o.id) ?? []).map((i) => ({ + id: i.id, + name: i.name, + price: i.price, + quantity: i.quantity, + })), + events: (eventsByOrder.get(o.id) ?? []).map((e) => ({ + status: e.status, + actor: e.actor, + actorName: e.actorName, + at: e.createdAt, + })), + })), + }; +} diff --git a/backend/src/orders/status.ts b/backend/src/orders/status.ts new file mode 100644 index 0000000..7bf2777 --- /dev/null +++ b/backend/src/orders/status.ts @@ -0,0 +1,44 @@ +import { eq } from 'drizzle-orm'; +import { ORDER_STATUS_TRANSITIONS, type OrderStatus } from '@menu/schemas'; +import * as schema from '../db/schema'; +import type { DbInstance } from '../db'; + +export async function transitionOrderStatus( + db: DbInstance, + orderId: string, + status: 'ready' | 'served' | 'rejected', + actor: 'admin' | 'staff', + actorName: string | null = null, + rejectReason?: string, +) { + const [order] = await db + .select({ status: schema.orders.status }) + .from(schema.orders) + .where(eq(schema.orders.id, orderId)) + .limit(1); + if (!order) return { error: 'not_found' as const }; + + const allowed = ORDER_STATUS_TRANSITIONS[order.status as OrderStatus] ?? []; + if (!allowed.includes(status)) { + return { error: 'illegal_transition' as const, from: order.status, to: status }; + } + + await db.batch([ + db + .update(schema.orders) + .set({ + status, + rejectReason: status === 'rejected' ? rejectReason : null, + updatedAt: Date.now(), + }) + .where(eq(schema.orders.id, orderId)), + db.insert(schema.orderEvents).values({ + id: crypto.randomUUID(), + orderId, + status, + actor, + actorName, + }), + ]); + return { ok: true as const, status }; +} diff --git a/backend/src/routes/admin-checks.ts b/backend/src/routes/admin-checks.ts index d9af26f..8465370 100644 --- a/backend/src/routes/admin-checks.ts +++ b/backend/src/routes/admin-checks.ts @@ -9,7 +9,7 @@ import { UpdateCheckBodySchema, SettleCheckBodySchema, computeCheckTotals, type import * as schema from '../db/schema'; import type { AppBindings } from '../types'; import type { DbInstance } from '../db'; -import { createOrder } from './orders'; +import { createOrder } from '../orders'; /** * Checks (conto, #15 follow-up): create/settle/void a table session's bill, plus diff --git a/backend/src/routes/admin-orders.ts b/backend/src/routes/admin-orders.ts index 0f5c2d8..c896578 100644 --- a/backend/src/routes/admin-orders.ts +++ b/backend/src/routes/admin-orders.ts @@ -1,20 +1,18 @@ import { Hono, type Context } from 'hono'; -import { eq, desc, asc, inArray } from 'drizzle-orm'; +import { eq, desc, asc } from 'drizzle-orm'; import { requireAuth } from '../middleware/auth'; import { requireAdmin } from '../middleware/admin-guard'; import { requireDb } from '../middleware/db'; import { parseBody } from '../lib/validate'; import { - ORDER_STATUS_TRANSITIONS, UpdateOrderStatusBodySchema, SetDestinationPrintedBodySchema, CreateOrderDestinationBodySchema, UpdateOrderDestinationBodySchema, - type OrderStatus, } from '@menu/schemas'; import * as schema from '../db/schema'; import { refreshCatalogArtifacts } from './catalog'; -import { currentOrderDay } from './orders'; +import { buildAdminOrdersForDay, currentOrderDay, transitionOrderStatus } from '../orders'; import type { AppBindings } from '../types'; /** @@ -31,79 +29,7 @@ const base = [requireAuth, requireDb, requireAdmin] as const; admin.get('/orders', ...base, async (c) => { const db = c.get('db'); const day = Number(c.req.query('day')) || currentOrderDay(new Date(), c.get('config').orderTimeZone); - - const orderRows = await db - .select({ - id: schema.orders.id, - dailyNumber: schema.orders.dailyNumber, - status: schema.orders.status, - rejectReason: schema.orders.rejectReason, - createdAt: schema.orders.createdAt, - tableName: schema.tables.name, - areaName: schema.areas.name, - updatedAt: schema.orders.updatedAt, - }) - .from(schema.orders) - .leftJoin(schema.tableSessions, eq(schema.orders.tableSessionId, schema.tableSessions.id)) - .leftJoin(schema.tables, eq(schema.tableSessions.tableId, schema.tables.id)) - .leftJoin(schema.areas, eq(schema.tables.areaId, schema.areas.id)) - .where(eq(schema.orders.orderDay, day)) - .orderBy(desc(schema.orders.dailyNumber)); - - const orderIds = orderRows.map((o) => o.id); - const itemRows = orderIds.length > 0 - ? await db.select().from(schema.orderItems).where(inArray(schema.orderItems.orderId, orderIds)) - : []; - const itemIds = itemRows.map((i) => i.id); - const destRows = itemIds.length > 0 - ? await db.select().from(schema.orderItemDestinations).where(inArray(schema.orderItemDestinations.orderItemId, itemIds)) - : []; - const eventRows = orderIds.length > 0 - ? await db.select().from(schema.orderEvents).where(inArray(schema.orderEvents.orderId, orderIds)) - : []; - const submittedBy = new Map(); - for (const e of eventRows) { - if (e.status === 'submitted' && e.actorName) submittedBy.set(e.orderId, e.actorName); - } - - const destsByItem = new Map(); - for (const d of destRows) { - const list = destsByItem.get(d.orderItemId) ?? []; - list.push(d); - destsByItem.set(d.orderItemId, list); - } - const itemsByOrder = new Map(); - for (const i of itemRows) { - const list = itemsByOrder.get(i.orderId) ?? []; - list.push(i); - itemsByOrder.set(i.orderId, list); - } - - return c.json({ - day, - orders: orderRows.map((o) => ({ - id: o.id, - dailyNumber: o.dailyNumber, - status: o.status, - rejectReason: o.rejectReason, - createdAt: o.createdAt, - updatedAt: o.updatedAt, - tableName: o.tableName ? (o.areaName ? `${o.areaName} · ${o.tableName}` : o.tableName) : null, - submittedBy: submittedBy.get(o.id) ?? null, - items: (itemsByOrder.get(o.id) ?? []).map((i) => ({ - id: i.id, - name: i.name, - price: i.price, - quantity: i.quantity, - destinations: (destsByItem.get(i.id) ?? []).map((d) => ({ - id: d.id, - destinationId: d.destinationId, - destinationName: d.destinationName, - printedAt: d.printedAt, - })), - })), - })), - }); + return c.json({ day, orders: await buildAdminOrdersForDay(db, day) }); }); /** Whole-order status transition: submitted → ready → served, or reject with reason. */ @@ -112,38 +38,12 @@ admin.patch('/orders/:orderId/status', ...base, async (c) => { const body = await parseBody(c, UpdateOrderStatusBodySchema); if (body instanceof Response) return body; - const db = c.get('db'); - const [order] = await db - .select({ status: schema.orders.status }) - .from(schema.orders) - .where(eq(schema.orders.id, orderId)) - .limit(1); - if (!order) return c.json({ error: 'Not Found' }, 404); - - const allowed = ORDER_STATUS_TRANSITIONS[order.status as OrderStatus] ?? []; - if (!allowed.includes(body.status)) { - return c.json({ error: 'illegal_transition', from: order.status, to: body.status }, 409); + const result = await transitionOrderStatus(c.get('db'), orderId, body.status, 'admin', null, body.rejectReason); + if (result.error === 'not_found') return c.json({ error: 'Not Found' }, 404); + if (result.error === 'illegal_transition') { + return c.json({ error: 'illegal_transition', from: result.from, to: result.to }, 409); } - - await db.batch([ - db - .update(schema.orders) - .set({ - status: body.status, - rejectReason: body.status === 'rejected' ? body.rejectReason : null, - updatedAt: Date.now(), - }) - .where(eq(schema.orders.id, orderId)), - db.insert(schema.orderEvents).values({ - id: crypto.randomUUID(), - orderId, - status: body.status, - actor: 'admin', - actorName: null, - }), - ]); - - return c.json({ ok: true, status: body.status }); + return c.json({ ok: true, status: result.status }); }); /** Per-department printed/done toggle — independent per destination row. */ diff --git a/backend/src/routes/orders.ts b/backend/src/routes/orders.ts index cd9938c..b63aafd 100644 --- a/backend/src/routes/orders.ts +++ b/backend/src/routes/orders.ts @@ -1,220 +1,17 @@ import { Hono } from 'hono'; -import { eq, and, inArray, isNull, count } from 'drizzle-orm'; +import { SubmitOrderBodySchema, CreateOrderIntentBodySchema } from '@menu/schemas'; import { requireDb } from '../middleware/db'; import { parseBody } from '../lib/validate'; -import { SubmitOrderBodySchema, CreateOrderIntentBodySchema, normalizeModulesConfig } from '@menu/schemas'; -import * as schema from '../db/schema'; import { STAFF_SESSION_HEADER, validateStaffSession } from '../lib/staff'; -import type { DbInstance } from '../db'; +import { createOrder, INTENT_TTL_MS, loadOrdering } from '../orders'; +import * as schema from '../db/schema'; import type { AppBindings } from '../types'; -/** YYYYMMDD integer bucket in the restaurant timezone. */ -export function currentOrderDay(now = new Date(), timeZone = 'UTC'): number { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(now); - const value = (type: string) => Number(parts.find((part) => part.type === type)!.value); - return value('year') * 10000 + value('month') * 100 + value('day'); -} - -/** Intents expire 30 minutes after creation (#19). */ -export const INTENT_TTL_MS = 30 * 60_000; - -const MAX_NUMBERING_RETRIES = 3; - -export type CreateOrderResult = - | { ok: true; orderId: string; dailyNumber: number } - | { error: 'stale_items'; staleEntryIds: string[] } - | { error: 'invalid_table_session' } - | { error: 'check_open' }; - -/** - * The single order-creation path (issue #17). Both the public direct submit - * and the waiter intent-consume (#19) go through here: idempotency, duplicate - * line merging, stale-item validation, frozen name/price snapshot, destination - * snapshot, and daily numbering with retry-on-conflict. - */ -export async function createOrder( - db: DbInstance, - idempotencyKey: string, - rawLines: { entryId: string; quantity: number }[], - tableSessionId?: string | null, - actor: 'diner' | 'staff' | 'admin' = tableSessionId ? 'staff' : 'diner', - actorName?: string | null, - orderTimeZone = 'UTC', -): Promise { - // Idempotency first: a retried submit returns the already-created order even - // if the table session has since closed (no false invalid_table_session). - const existing = await findByIdempotencyKey(db, idempotencyKey); - if (existing) return existing; - - // Guard the optional table session (#15): it must exist and be open, so an - // invalid id can't 500 on the FK and a closed session can't accept appends. - if (tableSessionId != null) { - const [session] = await db - .select({ id: schema.tableSessions.id }) - .from(schema.tableSessions) - .where(and(eq(schema.tableSessions.id, tableSessionId), isNull(schema.tableSessions.closedAt))) - .limit(1); - if (!session) return { error: 'invalid_table_session' }; - - // An open check freezes the session: no new orders while it awaits settle/void (#15). - const [openCheck] = await db - .select({ id: schema.checks.id }) - .from(schema.checks) - .where(and(eq(schema.checks.tableSessionId, tableSessionId), eq(schema.checks.status, 'open'))) - .limit(1); - if (openCheck) return { error: 'check_open' }; - } - - // Merge duplicate entryIds so one entry can't create two lines. - const quantities = new Map(); - for (const line of rawLines) { - quantities.set(line.entryId, (quantities.get(line.entryId) ?? 0) + line.quantity); - } - const entryIds = [...quantities.keys()]; - - // Stale-item validation: hidden, out-of-stock, or deleted entries block the - // whole order — the diner must remove them to send. - const entries = await db - .select({ - id: schema.menuEntries.id, - name: schema.menuEntries.name, - price: schema.menuEntries.price, - hidden: schema.menuEntries.hidden, - outOfStock: schema.menuEntries.outOfStock, - }) - .from(schema.menuEntries) - .where(inArray(schema.menuEntries.id, entryIds)); - const entryById = new Map(entries.map((e) => [e.id, e])); - const staleEntryIds = entryIds.filter((id) => { - const entry = entryById.get(id); - return !entry || entry.hidden || entry.outOfStock; - }); - if (staleEntryIds.length > 0) { - return { error: 'stale_items', staleEntryIds }; - } - - // Destination snapshot: resolve each entry's destinations now so routing - // survives later menu/destination edits. - const destinationRows = await db - .select({ - entryId: schema.entryDestinations.entryId, - destinationId: schema.orderDestinations.id, - destinationName: schema.orderDestinations.name, - }) - .from(schema.entryDestinations) - .innerJoin(schema.orderDestinations, eq(schema.entryDestinations.destinationId, schema.orderDestinations.id)) - .where(inArray(schema.entryDestinations.entryId, entryIds)); - const destinationsByEntry = new Map(); - for (const row of destinationRows) { - const list = destinationsByEntry.get(row.entryId) ?? []; - list.push({ destinationId: row.destinationId, destinationName: row.destinationName }); - destinationsByEntry.set(row.entryId, list); - } - - const orderId = crypto.randomUUID(); - const orderDay = currentOrderDay(new Date(), orderTimeZone); - - const itemValues = entryIds.map((entryId) => { - const entry = entryById.get(entryId)!; - return { - id: crypto.randomUUID(), - orderId, - entryId, - name: entry.name, - price: entry.price, - quantity: quantities.get(entryId)!, - }; - }); - const itemDestinationValues = itemValues.flatMap((item) => - (destinationsByEntry.get(item.entryId) ?? []).map((dest) => ({ - id: crypto.randomUUID(), - orderItemId: item.id, - destinationId: dest.destinationId, - destinationName: dest.destinationName, - })), - ); - - // Daily numbering: COUNT(*)+1, retried when a concurrent submit takes the - // same number (UNIQUE(order_day, daily_number) rejects the batch). - for (let attempt = 0; attempt < MAX_NUMBERING_RETRIES; attempt++) { - const [{ value: existingCount }] = await db - .select({ value: count() }) - .from(schema.orders) - .where(eq(schema.orders.orderDay, orderDay)); - const dailyNumber = existingCount + 1; - - try { - await db.batch([ - db.insert(schema.orders).values({ - id: orderId, - orderDay, - dailyNumber, - idempotencyKey, - tableSessionId: tableSessionId ?? null, - }), - db.insert(schema.orderItems).values(itemValues), - ...(itemDestinationValues.length > 0 - ? [db.insert(schema.orderItemDestinations).values(itemDestinationValues)] - : []), - db.insert(schema.orderEvents).values({ - id: crypto.randomUUID(), - orderId, - status: 'submitted', - actor, - actorName: actorName ?? null, - }), - ]); - return { ok: true, orderId, dailyNumber }; - } catch (error) { - // A concurrent request with the same idempotency key won the race. - const won = await findByIdempotencyKey(db, idempotencyKey); - if (won) return won; - // Otherwise assume daily-number collision and retry with a fresh count. - if (attempt === MAX_NUMBERING_RETRIES - 1) throw error; - } - } - - throw new Error('Could not assign order number'); -} - -/** Loads the normalized ordering config; null when unpublished or missing. */ -async function loadOrdering(db: DbInstance) { - const [settingsRow] = await db - .select({ - modules: schema.settings.modules, - aiChatEnabled: schema.settings.aiChatEnabled, - aiVoiceEnabled: schema.settings.aiVoiceEnabled, - publicationState: schema.settings.publicationState, - }) - .from(schema.settings) - .where(eq(schema.settings.id, 1)) - .limit(1); - if (!settingsRow || settingsRow.publicationState !== 'published') return null; - return normalizeModulesConfig(settingsRow.modules, settingsRow).ordering; -} +export { currentOrderDay, INTENT_TTL_MS } from '../orders'; export const orderRoutes = new Hono() - /** - * POST /orders - * - * Direct-submit endpoint. Rate limited in app.ts. Order creation lives in - * createOrder(), shared with the waiter intent-consume path (#19). - * - * Two callers, one path (no duplicate submit route per #15): - * - diner self-submit: requires submitMode diner|both; - * - waiter table order (#15): body carries tableSessionId + a valid - * X-Staff-Session header, which bypasses the diner submitMode gate so - * waiter-only configs can still take table orders. The module must still - * be enabled and in 'send' mode. - */ .post('/', requireDb, async (c) => { const db = c.get('db'); - const body = await parseBody(c, SubmitOrderBodySchema); if (body instanceof Response) return body; @@ -225,31 +22,27 @@ export const orderRoutes = new Hono() let staffName: string | null = null; if (body.tableSessionId != null) { - // Waiter table order: authenticate the staff session instead of the - // diner submitMode gate. const session = await validateStaffSession(db, c.req.header(STAFF_SESSION_HEADER)); if (!session) return c.json({ error: 'Unauthorized' }, 401); staffName = session.name; } else if (ordering.submitMode === 'waiter') { - // Diner self-submit is disabled in waiter-only mode. return c.json({ error: 'Not Found' }, 404); } - const result = await createOrder(db, body.idempotencyKey, body.lines, body.tableSessionId, body.tableSessionId ? 'staff' : 'diner', staffName, c.get('config').orderTimeZone); + const result = await createOrder( + db, + body.idempotencyKey, + body.lines, + body.tableSessionId, + body.tableSessionId ? 'staff' : 'diner', + staffName, + c.get('config').orderTimeZone, + ); if ('error' in result) return c.json(result, 409); return c.json(result); }) - /** - * POST /orders/intents - * - * Public intent creation (submitMode waiter|both): stores a minimal - * {entryId, quantity} snapshot and returns an opaque token the diner shows - * as a QR. Name/price/availability are resolved at review/consume time so - * the waiter always sees current data. Rate limited in app.ts. - */ .post('/intents', requireDb, async (c) => { const db = c.get('db'); - const body = await parseBody(c, CreateOrderIntentBodySchema); if (body instanceof Response) return body; @@ -260,20 +53,6 @@ export const orderRoutes = new Hono() const token = crypto.randomUUID(); const expiresAt = Date.now() + INTENT_TTL_MS; - await db.insert(schema.orderIntents).values({ - id: token, - lines: body.lines, - expiresAt, - }); + await db.insert(schema.orderIntents).values({ id: token, lines: body.lines, expiresAt }); return c.json({ ok: true, token, expiresAt }); }); - -async function findByIdempotencyKey(db: DbInstance, idempotencyKey: string) { - const [row] = await db - .select({ id: schema.orders.id, dailyNumber: schema.orders.dailyNumber }) - .from(schema.orders) - .where(eq(schema.orders.idempotencyKey, idempotencyKey)) - .limit(1); - if (!row) return null; - return { ok: true as const, orderId: row.id, dailyNumber: row.dailyNumber }; -} diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts index 764d79c..0d054c1 100644 --- a/backend/src/routes/staff.ts +++ b/backend/src/routes/staff.ts @@ -1,16 +1,14 @@ import { Hono } from 'hono'; -import { eq, and, asc, inArray, isNull } from 'drizzle-orm'; +import { eq, and, inArray, isNull } from 'drizzle-orm'; import { requireDb } from '../middleware/db'; import { requireStaff } from '../middleware/staff-guard'; import { parseBody } from '../lib/validate'; import { ConsumeStaffLinkBodySchema, ConsumeOrderIntentBodySchema, - ORDER_STATUS_TRANSITIONS, - type OrderStatus, } from '@menu/schemas'; import * as schema from '../db/schema'; -import { createOrder } from './orders'; +import { buildStaffSessionDetail, createOrder, transitionOrderStatus } from '../orders'; import { buildFloorState } from '../lib/floor'; import { checkRateLimit } from '../lib/rate-limit'; import type { AppBindings } from '../types'; @@ -106,77 +104,9 @@ staff.post('/tables/:id/session', ...staffBase, async (c) => { /** GET /staff/sessions/:id — the session's orders with status. */ staff.get('/sessions/:id', ...staffBase, async (c) => { - const db = c.get('db'); - const sessionId = c.req.param('id'); - - const [session] = await db - .select({ - id: schema.tableSessions.id, - openedAt: schema.tableSessions.openedAt, - tableId: schema.tables.id, - tableName: schema.tables.name, - areaName: schema.areas.name, - }) - .from(schema.tableSessions) - .innerJoin(schema.tables, eq(schema.tableSessions.tableId, schema.tables.id)) - .leftJoin(schema.areas, eq(schema.tables.areaId, schema.areas.id)) - .where(eq(schema.tableSessions.id, sessionId)) - .limit(1); - if (!session) return c.json({ error: 'Not Found' }, 404); - - const orderRows = await db - .select() - .from(schema.orders) - .where(eq(schema.orders.tableSessionId, sessionId)) - .orderBy(asc(schema.orders.createdAt)); - const orderIds = orderRows.map((o) => o.id); - const itemRows = orderIds.length > 0 - ? await db.select().from(schema.orderItems).where(inArray(schema.orderItems.orderId, orderIds)) - : []; - const eventRows = orderIds.length > 0 - ? await db - .select() - .from(schema.orderEvents) - .where(inArray(schema.orderEvents.orderId, orderIds)) - .orderBy(asc(schema.orderEvents.createdAt)) - : []; - const eventsByOrder = new Map(); - for (const e of eventRows) { - const list = eventsByOrder.get(e.orderId) ?? []; - list.push(e); - eventsByOrder.set(e.orderId, list); - } - const itemsByOrder = new Map(); - for (const i of itemRows) { - const list = itemsByOrder.get(i.orderId) ?? []; - list.push(i); - itemsByOrder.set(i.orderId, list); - } - - return c.json({ - sessionId: session.id, - tableId: session.tableId, - tableName: session.areaName ? `${session.areaName} · ${session.tableName}` : session.tableName, - openedAt: session.openedAt, - orders: orderRows.map((o) => ({ - id: o.id, - dailyNumber: o.dailyNumber, - status: o.status, - createdAt: o.createdAt, - items: (itemsByOrder.get(o.id) ?? []).map((i) => ({ - id: i.id, - name: i.name, - price: i.price, - quantity: i.quantity, - })), - events: (eventsByOrder.get(o.id) ?? []).map((e) => ({ - status: e.status, - actor: e.actor, - actorName: e.actorName, - at: e.createdAt, - })), - })), - }); + const detail = await buildStaffSessionDetail(c.get('db'), c.req.param('id')); + if (!detail) return c.json({ error: 'Not Found' }, 404); + return c.json(detail); }); // ── Mark served (staff) ────────────────────────────────────────────── @@ -187,34 +117,12 @@ staff.get('/sessions/:id', ...staffBase, async (c) => { * or reset an order, only confirm delivery. */ staff.patch('/orders/:orderId/serve', ...staffBase, async (c) => { - const db = c.get('db'); const orderId = c.req.param('orderId'); - - const [order] = await db - .select({ status: schema.orders.status }) - .from(schema.orders) - .where(eq(schema.orders.id, orderId)) - .limit(1); - if (!order) return c.json({ error: 'Not Found' }, 404); - - const allowed = ORDER_STATUS_TRANSITIONS[order.status as OrderStatus] ?? []; - if (order.status !== 'ready' || !allowed.includes('served')) { - return c.json({ error: 'illegal_transition', from: order.status, to: 'served' }, 409); + const result = await transitionOrderStatus(c.get('db'), orderId, 'served', 'staff', c.get('staff').name); + if (result.error === 'not_found') return c.json({ error: 'Not Found' }, 404); + if (result.error === 'illegal_transition') { + return c.json({ error: 'illegal_transition', from: result.from, to: 'served' }, 409); } - - await db.batch([ - db - .update(schema.orders) - .set({ status: 'served', updatedAt: Date.now() }) - .where(eq(schema.orders.id, orderId)), - db.insert(schema.orderEvents).values({ - id: crypto.randomUUID(), - orderId, - status: 'served', - actor: 'staff', - actorName: c.get('staff').name, - }), - ]); return c.json({ ok: true, status: 'served' }); }); diff --git a/web/src/app/[locale]/menu/MenuPageClient.test.tsx b/web/src/app/[locale]/menu/MenuPageClient.test.tsx index 5649dae..81473ad 100644 --- a/web/src/app/[locale]/menu/MenuPageClient.test.tsx +++ b/web/src/app/[locale]/menu/MenuPageClient.test.tsx @@ -18,9 +18,12 @@ vi.mock('@/lib/i18n', () => ({ vi.mock('@/lib/api', () => ({ recordView: vi.fn(), + ApiError: class ApiError extends Error { constructor(public status: number, message: string, public body?: unknown) { super(message); } }, +})); + +vi.mock('@/orders/api', () => ({ createOrderIntent: vi.fn(), submitOrder: vi.fn(), - ApiError: class ApiError extends Error { constructor(public status: number, message: string, public body?: unknown) { super(message); } }, })); vi.mock('next/image', () => ({ diff --git a/web/src/app/[locale]/selection/SelectionPageClient.test.tsx b/web/src/app/[locale]/selection/SelectionPageClient.test.tsx index dd28637..991d954 100644 --- a/web/src/app/[locale]/selection/SelectionPageClient.test.tsx +++ b/web/src/app/[locale]/selection/SelectionPageClient.test.tsx @@ -9,14 +9,10 @@ const loadRestaurantMock = vi.fn(); const submitOrderMock = vi.fn(); const createOrderIntentMock = vi.fn(); -vi.mock('@/lib/api', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - submitOrder: (...args: unknown[]) => submitOrderMock(...args), - createOrderIntent: (...args: unknown[]) => createOrderIntentMock(...args), - }; -}); +vi.mock('@/orders/api', () => ({ + submitOrder: (...args: unknown[]) => submitOrderMock(...args), + createOrderIntent: (...args: unknown[]) => createOrderIntentMock(...args), +})); vi.mock('next/navigation', () => ({ useParams: () => ({ locale: 'it' }), diff --git a/web/src/components/admin/pages/EditEntryPage.tsx b/web/src/components/admin/pages/EditEntryPage.tsx index d3b0ccb..43580e9 100644 --- a/web/src/components/admin/pages/EditEntryPage.tsx +++ b/web/src/components/admin/pages/EditEntryPage.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import Image from "next/image"; import { useSearchParams, useRouter } from "next/navigation"; import { uploadEntryImage, deleteEntryImage } from "@/lib/imageUpload"; -import { updateEntry, createEntry, deleteEntry, moveEntry, fetchOrderDestinations, type AdminOrderDestination } from "@/lib/api"; +import { updateEntry, createEntry, deleteEntry, moveEntry } from "@/lib/api"; +import { fetchOrderDestinations, type AdminOrderDestination } from "@/orders/api"; import { sanitizeI18nData } from "@/lib/i18n-admin"; import { useRestaurantStore, useCategories, useLabels } from "@/stores/restaurantStore"; import { LABEL_COLOR_STYLES, resolveLabel } from "@/lib/label-colors"; diff --git a/web/src/components/admin/pages/KitchenBoardPage.test.tsx b/web/src/components/admin/pages/KitchenBoardPage.test.tsx index 44416bb..450189b 100644 --- a/web/src/components/admin/pages/KitchenBoardPage.test.tsx +++ b/web/src/components/admin/pages/KitchenBoardPage.test.tsx @@ -10,7 +10,7 @@ const updateOrderStatusMock = vi.fn(); const setDestinationPrintedMock = vi.fn(); const fetchOrderDestinationsMock = vi.fn(); -vi.mock("@/lib/api", () => ({ +vi.mock("@/orders/api", () => ({ fetchAdminOrders: (...args: unknown[]) => fetchAdminOrdersMock(...args), updateOrderStatus: (...args: unknown[]) => updateOrderStatusMock(...args), setDestinationPrinted: (...args: unknown[]) => setDestinationPrintedMock(...args), diff --git a/web/src/components/admin/pages/KitchenBoardPage.tsx b/web/src/components/admin/pages/KitchenBoardPage.tsx index 325c454..e0a2cf6 100644 --- a/web/src/components/admin/pages/KitchenBoardPage.tsx +++ b/web/src/components/admin/pages/KitchenBoardPage.tsx @@ -11,7 +11,7 @@ import { deleteOrderDestination, type AdminOrder, type AdminOrderDestination, -} from "@/lib/api"; +} from "@/orders/api"; import { useRestaurantStore } from "@/stores/restaurantStore"; import { useTranslations } from "@/lib/i18n"; diff --git a/web/src/components/menu/SelectionContent.tsx b/web/src/components/menu/SelectionContent.tsx index 5192cc7..6afaf6d 100644 --- a/web/src/components/menu/SelectionContent.tsx +++ b/web/src/components/menu/SelectionContent.tsx @@ -6,7 +6,8 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { QRCodeSVG } from "qrcode.react"; import { Dialog, DialogPanel } from "@headlessui/react"; import { getLocalizedContentValue } from "@/lib/content-presentation"; -import { ApiError, createOrderIntent, submitOrder } from "@/lib/api"; +import { ApiError } from "@/lib/api"; +import { createOrderIntent, submitOrder } from "@/orders/api"; import { useTranslations } from "@/lib/i18n"; import type { MenuCategory, MenuEntry } from "@/lib/types"; import { useRestaurantStore } from "@/stores/restaurantStore"; diff --git a/web/src/components/staff/OrderReviewPage.test.tsx b/web/src/components/staff/OrderReviewPage.test.tsx index dd7f2d5..9a5ea1c 100644 --- a/web/src/components/staff/OrderReviewPage.test.tsx +++ b/web/src/components/staff/OrderReviewPage.test.tsx @@ -11,9 +11,14 @@ const apiMocks = vi.hoisted(() => ({ })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, ...apiMocks }; + return { ...actual, fetchFloor: apiMocks.fetchFloor, openTableSession: apiMocks.openTableSession, getCatalog: apiMocks.getCatalog }; }); +vi.mock("@/orders/api", () => ({ + fetchOrderIntent: apiMocks.fetchOrderIntent, + consumeOrderIntent: apiMocks.consumeOrderIntent, +})); + vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams("token=tok-123"), })); diff --git a/web/src/components/staff/OrderReviewPage.tsx b/web/src/components/staff/OrderReviewPage.tsx index 4b627a1..c9baafe 100644 --- a/web/src/components/staff/OrderReviewPage.tsx +++ b/web/src/components/staff/OrderReviewPage.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { ApiError, consumeOrderIntent, fetchFloor, fetchOrderIntent, getCatalog, openTableSession } from "@/lib/api"; +import { ApiError, fetchFloor, getCatalog, openTableSession } from "@/lib/api"; +import { consumeOrderIntent, fetchOrderIntent } from "@/orders/api"; import type { CatalogResponse, FloorTable, OrderIntentReviewResponse } from "@menu/schemas"; import { getLocalizedContentValue } from "@/lib/content-presentation"; import { useLocale, useTranslations } from "@/lib/i18n"; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5dcc1ca..ead1daf 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -32,13 +32,8 @@ import type { NormalizedModulesConfig, ModulesConfig, ImageUploadResponse, - SubmitOrderBody, SubmitOrderLine, SubmitOrderResponse, - UpdateOrderStatusBody, - CreateOrderIntentBody, - CreateOrderIntentResponse, - OrderIntentReviewResponse, CreateStaffLinkBody, CreatedStaffLinkResponse, StaffLinkSummary, @@ -113,7 +108,7 @@ export function clearStaffSession(): void { } } -async function apiFetch(path: string, options: FetchOptions = {}): Promise { +export async function apiFetch(path: string, options: FetchOptions = {}): Promise { const { method = 'GET', body, headers = {}, auth = false, staff = false } = options; if (staff) { @@ -187,37 +182,6 @@ export function getCatalog() { return apiFetch(`/catalog?t=${Date.now()}`); } -/** - * Submit an order (public, rate-limited, idempotent via idempotencyKey). - * With a tableSessionId (waiter mode, #15) it also sends the staff session - * header so the shared /orders route authenticates the waiter. - */ -export function submitOrder(body: SubmitOrderBody) { - return apiFetch('/orders', { method: 'POST', body, staff: !!body.tableSessionId }); -} - -/** Create a waiter-handoff order intent (public); the token goes into the QR link. */ -export function createOrderIntent(body: CreateOrderIntentBody) { - return apiFetch('/orders/intents', { method: 'POST', body }); -} - -/** Load an order intent for waiter review (staff, #15). Lines reflect the current menu. */ -export function fetchOrderIntent(token: string) { - return apiFetch(`/staff/order-intents/${encodeURIComponent(token)}`, { staff: true }); -} - -/** Consume an intent into a real order (staff). 409: expired | consumed | stale_items. Optional table session + edited lines override. */ -export function consumeOrderIntent(token: string, opts?: { tableSessionId?: string; lines?: SubmitOrderLine[] }) { - const body: Record = {}; - if (opts?.tableSessionId) body.tableSessionId = opts.tableSessionId; - if (opts?.lines) body.lines = opts.lines; - return apiFetch(`/staff/order-intents/${encodeURIComponent(token)}/consume`, { - method: 'POST', - body: Object.keys(body).length > 0 ? body : undefined, - staff: true, - }); -} - // ── Staff / waiter mode (#15) ──────────────────────────────── /** Exchange a one-use link token for a session (public). */ @@ -669,78 +633,6 @@ export function publishCatalog() { }); } -// ── Kitchen board / order destinations (#18) ──────────────────────── - -export interface AdminOrderItemDestination { - id: string; - destinationId: string | null; - destinationName: string; - printedAt: number | null; -} - -export interface AdminOrderItem { - id: string; - name: string; - price: number; - quantity: number; - destinations: AdminOrderItemDestination[]; -} - -export interface AdminOrder { - id: string; - dailyNumber: number; - status: 'submitted' | 'ready' | 'served' | 'rejected'; - rejectReason: string | null; - createdAt: number; - updatedAt: number; - tableName: string | null; - submittedBy: string | null; - items: AdminOrderItem[]; -} - -export function fetchAdminOrders(day?: number) { - const qs = day ? `?day=${day}` : ''; - return apiFetch<{ day: number; orders: AdminOrder[] }>(`/admin/orders${qs}`, { auth: true }); -} - -export function updateOrderStatus(orderId: string, body: UpdateOrderStatusBody) { - return apiFetch<{ ok: true; status: string }>(`/admin/orders/${encodeURIComponent(orderId)}/status`, { - method: 'PATCH', - body, - auth: true, - }); -} - -export function setDestinationPrinted(rowId: string, printed: boolean) { - return apiFetch<{ ok: true; printedAt: number | null }>(`/admin/order-item-destinations/${encodeURIComponent(rowId)}/printed`, { - method: 'PATCH', - body: { printed }, - auth: true, - }); -} - -export interface AdminOrderDestination { - id: string; - name: string; - sortOrder: number; -} - -export function fetchOrderDestinations() { - return apiFetch<{ destinations: AdminOrderDestination[] }>(`/admin/order-destinations`, { auth: true }); -} - -export function createOrderDestination(name: string) { - return apiFetch(`/admin/order-destinations`, { method: 'POST', body: { name }, auth: true }); -} - -export function updateOrderDestination(id: string, name: string) { - return apiFetch(`/admin/order-destinations/${encodeURIComponent(id)}`, { method: 'PATCH', body: { name }, auth: true }); -} - -export function deleteOrderDestination(id: string) { - return apiFetch(`/admin/order-destinations/${encodeURIComponent(id)}`, { method: 'DELETE', auth: true }); -} - // ── Analytics ──────────────────────────────────────────────────────── export function getAnalytics( diff --git a/web/src/orders/api.ts b/web/src/orders/api.ts new file mode 100644 index 0000000..9d0aedc --- /dev/null +++ b/web/src/orders/api.ts @@ -0,0 +1,106 @@ +import type { + CreatedEntryResponse, + CreateOrderIntentBody, + CreateOrderIntentResponse, + OrderIntentReviewResponse, + SubmitOrderBody, + SubmitOrderLine, + SubmitOrderResponse, + UpdateOrderStatusBody, +} from '@menu/schemas'; +import { apiFetch } from '@/lib/api'; + +export type { SubmitOrderLine }; + +export function submitOrder(body: SubmitOrderBody) { + return apiFetch('/orders', { method: 'POST', body, staff: !!body.tableSessionId }); +} + +export function createOrderIntent(body: CreateOrderIntentBody) { + return apiFetch('/orders/intents', { method: 'POST', body }); +} + +export function fetchOrderIntent(token: string) { + return apiFetch(`/staff/order-intents/${encodeURIComponent(token)}`, { staff: true }); +} + +export function consumeOrderIntent(token: string, opts?: { tableSessionId?: string; lines?: SubmitOrderLine[] }) { + const body: Record = {}; + if (opts?.tableSessionId) body.tableSessionId = opts.tableSessionId; + if (opts?.lines) body.lines = opts.lines; + return apiFetch(`/staff/order-intents/${encodeURIComponent(token)}/consume`, { + method: 'POST', + body: Object.keys(body).length > 0 ? body : undefined, + staff: true, + }); +} + +export interface AdminOrderItemDestination { + id: string; + destinationId: string | null; + destinationName: string; + printedAt: number | null; +} + +export interface AdminOrderItem { + id: string; + name: string; + price: number; + quantity: number; + destinations: AdminOrderItemDestination[]; +} + +export interface AdminOrder { + id: string; + dailyNumber: number; + status: 'submitted' | 'ready' | 'served' | 'rejected'; + rejectReason: string | null; + createdAt: number; + updatedAt: number; + tableName: string | null; + submittedBy: string | null; + items: AdminOrderItem[]; +} + +export function fetchAdminOrders(day?: number) { + const qs = day ? `?day=${day}` : ''; + return apiFetch<{ day: number; orders: AdminOrder[] }>(`/admin/orders${qs}`, { auth: true }); +} + +export function updateOrderStatus(orderId: string, body: UpdateOrderStatusBody) { + return apiFetch<{ ok: true; status: string }>(`/admin/orders/${encodeURIComponent(orderId)}/status`, { + method: 'PATCH', + body, + auth: true, + }); +} + +export function setDestinationPrinted(rowId: string, printed: boolean) { + return apiFetch<{ ok: true; printedAt: number | null }>(`/admin/order-item-destinations/${encodeURIComponent(rowId)}/printed`, { + method: 'PATCH', + body: { printed }, + auth: true, + }); +} + +export interface AdminOrderDestination { + id: string; + name: string; + sortOrder: number; +} + +export function fetchOrderDestinations() { + return apiFetch<{ destinations: AdminOrderDestination[] }>(`/admin/order-destinations`, { auth: true }); +} + +export function createOrderDestination(name: string) { + return apiFetch(`/admin/order-destinations`, { method: 'POST', body: { name }, auth: true }); +} + +export function updateOrderDestination(id: string, name: string) { + return apiFetch(`/admin/order-destinations/${encodeURIComponent(id)}`, { method: 'PATCH', body: { name }, auth: true }); +} + +export function deleteOrderDestination(id: string) { + return apiFetch(`/admin/order-destinations/${encodeURIComponent(id)}`, { method: 'DELETE', auth: true }); +} From 2bd3afd642ace7c2b8290e4ec22bf2564cdab677 Mon Sep 17 00:00:00 2001 From: Andrea Baccega Date: Tue, 7 Jul 2026 22:42:52 +0200 Subject: [PATCH 2/4] feat(self-host): add Docker SQLite deployment path Closes #16 --- .dockerignore | 10 + .env.self-host.example | 16 + .pii-allowlist | 4 + Caddyfile | 40 + Dockerfile | 25 + README.md | 30 +- backend/README.md | 4 +- backend/package.json | 3 + backend/src/db/index.ts | 23 +- backend/src/lib/env.ts | 11 +- backend/src/middleware/auth.ts | 25 +- backend/src/middleware/logging.ts | 6 +- backend/src/routes/staff.ts | 11 +- backend/src/self-host/fs-bucket.ts | 91 ++ backend/src/self-host/server.ts | 58 + backend/src/self-host/sqlite.ts | 73 ++ backend/src/types.ts | 7 +- docker-compose.yml | 61 + docs/secrets-and-env-vars.md | 14 + docs/self-hosting-without-cloudflare.md | 83 ++ docs/self-hosting.md | 4 +- package-lock.json | 29 +- package.json | 5 +- scripts/serve-static.mjs | 38 + web/workers/chat/package-lock.json | 1029 ++++++++++++++++- web/workers/chat/package.json | 7 + web/workers/chat/src/chat/handler.ts | 6 +- web/workers/chat/src/index.ts | 6 +- web/workers/chat/src/menu/cache.ts | 26 +- web/workers/chat/src/menu/d1.test.ts | 2 +- web/workers/chat/src/middleware/cors.ts | 6 +- .../chat/src/middleware/daily-cap.test.ts | 2 +- web/workers/chat/src/middleware/daily-cap.ts | 30 +- web/workers/chat/src/middleware/menu-guard.ts | 5 +- web/workers/chat/src/self-host/server.ts | 36 + web/workers/chat/src/self-host/sqlite.ts | 85 ++ web/workers/chat/src/types.ts | 13 +- 37 files changed, 1834 insertions(+), 90 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.self-host.example create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 backend/src/self-host/fs-bucket.ts create mode 100644 backend/src/self-host/server.ts create mode 100644 backend/src/self-host/sqlite.ts create mode 100644 docker-compose.yml create mode 100644 docs/self-hosting-without-cloudflare.md create mode 100644 scripts/serve-static.mjs create mode 100644 web/workers/chat/src/self-host/server.ts create mode 100644 web/workers/chat/src/self-host/sqlite.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..57e3b9c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/node_modules +**/.next +**/out +**/.wrangler +.worktrees +.self-host +data +.git +.env* +.tony-menu.local.json diff --git a/.env.self-host.example b/.env.self-host.example new file mode 100644 index 0000000..fda03c5 --- /dev/null +++ b/.env.self-host.example @@ -0,0 +1,16 @@ +PUBLIC_URL= +ADMIN_USER=admin +ADMIN_EMAIL=admin@example.com +ADMIN_EMAILS=admin@example.com +ADMIN_PASSWORD_HASH= +ORDER_TIME_ZONE=UTC +ALLOWED_HOST_SUFFIXES=localhost + +LLM_PROVIDER=openai +LLM_MODEL=gpt-4o-mini +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +DAILY_AI_REQUEST_LIMIT= + +CHAT_SESSION_SECRET=change-me +REFRESH_SECRET=change-me diff --git a/.pii-allowlist b/.pii-allowlist index b1597ed..d59ac09 100644 --- a/.pii-allowlist +++ b/.pii-allowlist @@ -148,6 +148,10 @@ test\.local (?i)^tony(-chat)?$ # npm registry tarball URLs in package-lock files are public dependency metadata. registry[.]npmjs[.]org +^https?://([a-z0-9-]+\.)*example(/.*)?$ +^https?://github\.com/sponsors/[a-z0-9-]+$ +^https?://www\.patreon\.com/[a-z0-9-]+$ +^https?://[a-z0-9-]+\.org/support$ # ── Demo restaurant seed data (backend/src/lib/demo-seed-data.ts) ── # Placeholder contact details for the public "Trattoria Demo" — all fake. diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..530d660 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,40 @@ +:80 { + @admin path /admin* /api/admin* /api/catalog/publish + basicauth @admin { + {$ADMIN_USER} {$ADMIN_PASSWORD_HASH} + } + + handle /api/admin* { + uri strip_prefix /api + reverse_proxy backend:8787 { + header_up X-Forwarded-Email {$ADMIN_EMAIL} + header_up X-Forwarded-Name {$ADMIN_USER} + } + } + + handle /api/catalog/publish { + uri strip_prefix /api + reverse_proxy backend:8787 { + header_up X-Forwarded-Email {$ADMIN_EMAIL} + header_up X-Forwarded-Name {$ADMIN_USER} + } + } + + handle /api/* { + uri strip_prefix /api + reverse_proxy backend:8787 + } + + handle /chat/* { + uri strip_prefix /chat + reverse_proxy chat:8788 + } + + handle /assets/* { + reverse_proxy backend:8787 + } + + handle { + reverse_proxy web:3000 + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2214a11 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-bookworm + +WORKDIR /app + +COPY package*.json ./ +COPY backend/package*.json backend/ +COPY web/package*.json web/ +COPY packages/schemas/package.json packages/schemas/ +RUN npm ci + +COPY web/workers/chat/package*.json web/workers/chat/ +RUN cd web/workers/chat && npm ci + +COPY . . + +ARG NEXT_PUBLIC_API_URL=/api +ARG NEXT_PUBLIC_CHAT_WORKER_URL=/chat +ARG NEXT_PUBLIC_DEFAULT_LOCALE=en +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ + NEXT_PUBLIC_CHAT_WORKER_URL=$NEXT_PUBLIC_CHAT_WORKER_URL \ + NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE +RUN NEXT_IGNORE_INCORRECT_LOCKFILE=1 npm --workspace web run build + +EXPOSE 3000 8787 8788 +CMD ["node", "scripts/serve-static.mjs", "web/out", "3000"] diff --git a/README.md b/README.md index 9405ea3..0fc4456 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TonyMenu -Self-hostable digital restaurant menu, built on Next.js and Cloudflare. Diners scan a QR code, browse a localized menu, and can ask an optional AI assistant for recommendations. +Self-hostable digital restaurant menu, built on Next.js. Cloudflare is the recommended hosted target; Docker + SQLite is available when you do not want a Cloudflare account. Diners scan a QR code, browse a localized menu, and can ask an optional AI assistant for recommendations. Restaurant owners manage the menu from `/admin`. QR codes should point to `/`; the app detects the locale and redirects diners to the localized menu. @@ -46,18 +46,29 @@ service, embedding it in a paid product) requires a separate license. See | Component | What | |---|---| -| `web/` | Next.js 16 (App Router), deployed to Cloudflare Pages | -| `backend/` | Hono API on Cloudflare Workers, Drizzle ORM over Cloudflare D1 | -| `web/workers/chat/` | Separate Cloudflare Worker for the AI chat assistant with SSE streaming and tool calls | +| `web/` | Next.js 16 (App Router), deployed to Cloudflare Pages or static Docker | +| `backend/` | Hono API on Cloudflare Workers/D1 or Node/SQLite | +| `web/workers/chat/` | Chat service on Cloudflare Workers or Node, with SSE streaming and tool calls | | `packages/schemas/` | Shared Zod schemas (`@menu/schemas`) | -| Auth | Cloudflare Access with backend JWT verification | -| Storage | Cloudflare R2 for images and catalog snapshots, Cloudflare KV for the chat menu cache | +| Auth | Cloudflare Access, or trusted reverse-proxy header in Docker | +| Storage | R2/KV on Cloudflare, or filesystem uploads + in-memory chat cache in Docker | ## Self-hosting -Full walkthrough: **[docs/self-hosting.md](docs/self-hosting.md)**. +- Cloudflare walkthrough: **[docs/self-hosting.md](docs/self-hosting.md)**. +- No-Cloudflare Docker walkthrough: **[docs/self-hosting-without-cloudflare.md](docs/self-hosting-without-cloudflare.md)**. -Prerequisites: Node 22+, npm 10+, Git, and a Cloudflare account with Zero Trust enabled. +Cloudflare prerequisites: Node 22+, npm 10+, Git, and a Cloudflare account with Zero Trust enabled. + +No-Cloudflare quick start: + +```bash +cp .env.self-host.example .env +# set ADMIN_PASSWORD_HASH; see docs/self-hosting-without-cloudflare.md +docker compose up --build +``` + +Then open the proxy on port 8080. Quick local setup: ```bash @@ -147,7 +158,8 @@ admin UI changes that require operator action) live in [CHANGELOG.md](CHANGELOG. ## Documentation - [Changelog](CHANGELOG.md) — per-release notes including upgrade hints -- [Self-hosting guide](docs/self-hosting.md) — deploy your own copy +- [Cloudflare self-hosting guide](docs/self-hosting.md) — deploy your own copy on Cloudflare +- [Docker self-hosting guide](docs/self-hosting-without-cloudflare.md) — deploy without Cloudflare - [Secrets & env vars](docs/secrets-and-env-vars.md) — full reference - [Architecture & coding conventions](CLAUDE.md) - [Contributing](CONTRIBUTING.md) diff --git a/backend/README.md b/backend/README.md index 8f1dd62..6e59c21 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # TonyMenu backend -Cloudflare Worker REST API for TonyMenu. +REST API for TonyMenu. Runs on Cloudflare Workers/D1 or the self-host Node + SQLite entrypoint. ## Stack @@ -25,6 +25,7 @@ Cloudflare Worker REST API for TonyMenu. cd backend npm install npm run dev # wrangler dev +npm run start:self-host # Node + SQLite + filesystem bucket npm run check # tsc --noEmit npm run test:run # Vitest npm run deploy # wrangler deploy @@ -57,6 +58,7 @@ Important bindings/vars: routes fall back or return a clear 503 where R2 is required. - `R2_PUBLIC_URL` — public base URL for R2 images. - `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` — Cloudflare Access JWT verification. +- `SELF_HOST_AUTH_HEADER` — trusted reverse-proxy email header for Node self-host. - `ALLOWED_ORIGINS` — comma-separated CORS allowlist. - `OPENAI_API_KEY` — secret used by translation and OpenAI chat flows. diff --git a/backend/package.json b/backend/package.json index 46e98c5..fc3b31b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,8 @@ "type": "module", "scripts": { "dev": "wrangler dev --persist-to .wrangler/state", + "dev:self-host": "tsx src/self-host/server.ts", + "start:self-host": "tsx src/self-host/server.ts", "deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)} --var ORDER_TIME_ZONE:${ORDER_TIME_ZONE:-$(node -e \"console.log(require('../.tony-menu.local.json').backend.orderTimeZone || 'UTC')\")}", "check": "tsc --noEmit", "typegen": "wrangler types", @@ -20,6 +22,7 @@ "test:migration-sql": "grep -E 'CREATE TYPE|jsonb|timestamp with time|numeric\\(' drizzle/*.sql 2>/dev/null && echo 'FAIL: postgres-isms found' && exit 1 || echo 'PASS: SQLite-only SQL'" }, "dependencies": { + "@hono/node-server": "^1.19.14", "@menu/schemas": "*", "@types/better-sqlite3": "^7.6.13", "better-sqlite3": "^12.9.0", diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index df8ec2a..d0af608 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,9 +1,24 @@ -import { drizzle } from 'drizzle-orm/d1'; +import { drizzle as drizzleD1 } from 'drizzle-orm/d1'; +import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3'; import type { Env } from '../types'; -export function createDb(env: Env) { - if (!env.DB) return null; - return drizzle(env.DB); +type D1Db = ReturnType; + +function withBatch(db: object): D1Db { + if ('batch' in db) return db as D1Db; + return Object.assign(db, { + async batch(queries: unknown[]) { + const results: unknown[] = []; + for (const query of queries) results.push(await (query as { execute: () => Promise }).execute()); + return results; + }, + }) as D1Db; +} + +export function createDb(env: Env): D1Db | null { + if (env.SQLITE_DB) return withBatch(drizzleSqlite(env.SQLITE_DB)); + if (env.DB) return drizzleD1(env.DB); + return null; } export type DbInstance = NonNullable>; diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts index 402802b..a5d1f29 100644 --- a/backend/src/lib/env.ts +++ b/backend/src/lib/env.ts @@ -9,6 +9,7 @@ const envSchema = z.object({ ORDER_TIME_ZONE: z.string().min(1).default('UTC').refine(isTimeZone, 'Invalid time zone'), ACCESS_TEAM_DOMAIN: z.string().min(1).optional(), ACCESS_AUD: z.string().min(1).optional(), + SELF_HOST_AUTH_HEADER: z.string().min(1).optional(), }); function isTimeZone(value: string): boolean { @@ -29,12 +30,18 @@ export function getRuntimeConfig(env: Env): RuntimeConfig { serviceName: parsed.SERVICE_NAME, commitSha: parsed.COMMIT_SHA, orderTimeZone: parsed.ORDER_TIME_ZONE, - databaseMode: env.DB ? 'd1' : 'unconfigured', + databaseMode: env.SQLITE_DB ? 'sqlite' : env.DB ? 'd1' : 'unconfigured', hasPublicMenuBucket: Boolean(env.PUBLIC_MENU_BUCKET), auth: { issuer: parsed.ACCESS_TEAM_DOMAIN, audience: parsed.ACCESS_AUD, - configured: Boolean(parsed.ACCESS_TEAM_DOMAIN && parsed.ACCESS_AUD), + trustedHeader: parsed.SELF_HOST_AUTH_HEADER, + mode: parsed.ACCESS_TEAM_DOMAIN && parsed.ACCESS_AUD + ? 'cloudflare-access' + : parsed.SELF_HOST_AUTH_HEADER + ? 'trusted-header' + : 'unconfigured', + configured: Boolean((parsed.ACCESS_TEAM_DOMAIN && parsed.ACCESS_AUD) || parsed.SELF_HOST_AUTH_HEADER), }, }; } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 26ca404..cc4b558 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -155,14 +155,10 @@ async function verifyJwt( } /** - * Auth middleware: requires a valid Cloudflare Access JWT. + * Auth middleware: Cloudflare Access in Workers, trusted proxy header in self-host. * - * Cloudflare Access sits in front of the worker and adds the - * `Cf-Access-Jwt-Assertion` header on every authenticated request. We verify - * the signature against the team's public keys and use the `email` claim as - * the stable user identifier. - * - * Sets `c.get('user')` with the authenticated user info. + * Self-host deployments must block direct backend access. Otherwise clients can spoof + * the trusted email header. */ export const requireAuth = createMiddleware(async (c, next) => { const config = c.get('config'); @@ -182,6 +178,20 @@ export const requireAuth = createMiddleware(async (c, next) => { return c.json({ error: 'Auth not configured on this backend instance' }, 503); } + if (config.auth.mode === 'trusted-header') { + const headerName = config.auth.trustedHeader || 'x-forwarded-email'; + const email = c.req.header(headerName); + if (!email) return c.json({ error: `Missing ${headerName} header` }, 401); + c.set('user', { + uid: email, + email, + name: c.req.header('x-forwarded-name') || undefined, + claims: { trustedHeader: headerName }, + }); + await next(); + return; + } + const token = c.req.header('Cf-Access-Jwt-Assertion'); if (!token) { return c.json({ error: 'Missing Cf-Access-Jwt-Assertion header' }, 401); @@ -197,7 +207,6 @@ export const requireAuth = createMiddleware(async (c, next) => { name: payload.name as string | undefined, claims: payload, }; - c.set('user', user); await next(); } catch (err) { diff --git a/backend/src/middleware/logging.ts b/backend/src/middleware/logging.ts index c539dff..1af3fbb 100644 --- a/backend/src/middleware/logging.ts +++ b/backend/src/middleware/logging.ts @@ -24,13 +24,13 @@ export const requestLogger = createMiddleware(async (c, next) => { status, duration_ms: duration, cf_ray: c.req.header('cf-ray'), - ip: c.req.header('cf-connecting-ip'), + ip: c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for'), }), ); }); /** - * Per-IP rate limiter using Cloudflare's cf-connecting-ip. + * Per-IP rate limiter using cf-connecting-ip or x-forwarded-for. * Delegates to the shared sliding-window limiter in lib/rate-limit. * `scope` keeps each mount's budget separate — without it all mounts share * one per-IP counter and staff/customer traffic starves the admin budget. @@ -41,7 +41,7 @@ export function rateLimit(scope: string, maxRequests: number, windowMs: number) await next(); return; } - const ip = c.req.header('cf-connecting-ip') || 'unknown'; + const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown'; const limited = checkRateLimit(`ip:${scope}:${ip}`, maxRequests, windowMs); if (limited) return limited; await next(); diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts index 0d054c1..a0ba33f 100644 --- a/backend/src/routes/staff.ts +++ b/backend/src/routes/staff.ts @@ -29,7 +29,7 @@ staff.post('/consume', requireDb, async (c) => { if (body instanceof Response) return body; // Public endpoint — throttle brute-force token guessing per IP. - const ip = c.req.header('cf-connecting-ip') ?? 'unknown'; + const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown'; const limited = checkRateLimit(`staff-consume:${ip}`, 20, 60_000); if (limited) return limited; @@ -53,7 +53,7 @@ staff.post('/consume', requireDb, async (c) => { .update(schema.staffLinks) .set({ consumedAt: Date.now(), sessionToken, lastSeenAt: Date.now() }) .where(and(eq(schema.staffLinks.id, link.id), isNull(schema.staffLinks.consumedAt))); - if (claim.meta.changes === 0) return c.json({ error: 'consumed' }, 409); + if (changedRows(claim) === 0) return c.json({ error: 'consumed' }, 409); return c.json({ ok: true, sessionToken, name: link.name }); }); @@ -207,7 +207,7 @@ staff.post('/order-intents/:token/consume', ...staffBase, async (c) => { .update(schema.orderIntents) .set({ consumedAt: Date.now() }) .where(and(eq(schema.orderIntents.id, token), isNull(schema.orderIntents.consumedAt))); - if (claim.meta.changes === 0) return c.json({ error: 'consumed' }, 409); + if (changedRows(claim) === 0) return c.json({ error: 'consumed' }, 409); let result; try { @@ -223,6 +223,11 @@ staff.post('/order-intents/:token/consume', ...staffBase, async (c) => { return c.json(result); }); +function changedRows(result: unknown): number { + const r = result as { changes?: number; meta?: { changes?: number } }; + return r.meta?.changes ?? r.changes ?? 0; +} + async function releaseClaim(db: DbInstance, token: string): Promise { await db .update(schema.orderIntents) diff --git a/backend/src/self-host/fs-bucket.ts b/backend/src/self-host/fs-bucket.ts new file mode 100644 index 0000000..f7afc1e --- /dev/null +++ b/backend/src/self-host/fs-bucket.ts @@ -0,0 +1,91 @@ +import { createReadStream } from 'node:fs'; +import type { Dirent } from 'node:fs'; +import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, join, normalize, sep } from 'node:path'; +import { Readable } from 'node:stream'; + +function safePath(root: string, key: string): string { + const full = normalize(join(root, key)); + const normalizedRoot = normalize(root + sep); + if (!full.startsWith(normalizedRoot)) throw new Error('Invalid object key'); + return full; +} + +function object(root: string, key: string, data: Buffer, contentType?: string): R2ObjectBody { + return { + key, + version: '', + size: data.byteLength, + etag: String(data.byteLength), + httpEtag: String(data.byteLength), + uploaded: new Date(), + httpMetadata: contentType ? { contentType } : {}, + customMetadata: {}, + checksums: {}, + range: undefined, + body: Readable.toWeb(createReadStream(safePath(root, key))) as ReadableStream, + bodyUsed: false, + arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength), + blob: async () => new Blob([data], { type: contentType }), + json: async () => JSON.parse(data.toString('utf8')), + text: async () => data.toString('utf8'), + writeHttpMetadata(headers: Headers) { + if (contentType) headers.set('content-type', contentType); + }, + } as R2ObjectBody; +} + +async function walk(root: string, dir: string, prefix: string, out: R2Object[]): Promise { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(root, full, prefix, out); + continue; + } + const key = full.slice(normalize(root + sep).length).replaceAll(sep, '/'); + if (!key.startsWith(prefix)) continue; + const info = await stat(full); + out.push({ key, version: '', size: info.size, etag: String(info.size), httpEtag: String(info.size), uploaded: info.mtime, httpMetadata: {}, customMetadata: {}, checksums: {} } as R2Object); + } +} + +export function createFsBucket(root: string): R2Bucket { + return { + async get(key: string) { + try { + const data = await readFile(safePath(root, key)); + return object(root, key, data); + } catch { + return null; + } + }, + async put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions) { + const full = safePath(root, key); + await mkdir(dirname(full), { recursive: true }); + let data: Buffer; + if (typeof value === 'string') data = Buffer.from(value); + else if (value instanceof Blob) data = Buffer.from(await value.arrayBuffer()); + else if (value instanceof ReadableStream) data = Buffer.from(await new Response(value).arrayBuffer()); + else if (value instanceof ArrayBuffer) data = Buffer.from(value); + else if (ArrayBuffer.isView(value)) data = Buffer.from(value.buffer, value.byteOffset, value.byteLength); + else data = Buffer.alloc(0); + await writeFile(full, data); + const contentType = options?.httpMetadata instanceof Headers ? options.httpMetadata.get('content-type') ?? undefined : options?.httpMetadata?.contentType; + return object(root, key, data, contentType); + }, + async delete(keys: string | string[]) { + for (const key of Array.isArray(keys) ? keys : [keys]) await rm(safePath(root, key), { force: true }); + }, + async list(options?: R2ListOptions) { + const objects: R2Object[] = []; + await walk(root, root, options?.prefix ?? '', objects); + return { objects, truncated: false, delimitedPrefixes: [], cursor: undefined }; + }, + } as unknown as R2Bucket; +} diff --git a/backend/src/self-host/server.ts b/backend/src/self-host/server.ts new file mode 100644 index 0000000..2dbe9bc --- /dev/null +++ b/backend/src/self-host/server.ts @@ -0,0 +1,58 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { serve } from '@hono/node-server'; +import { createApp } from '../app'; +import type { Env } from '../types'; +import { createFsBucket } from './fs-bucket'; +import { applyMigrations, createD1Compat, openSelfHostSqlite } from './sqlite'; + +const root = fileURLToPath(new URL('../../..', import.meta.url)); +const port = Number(process.env.PORT || 8787); +const dataDir = process.env.DATA_DIR || join(root, '.self-host'); +const dbPath = process.env.SQLITE_PATH || join(dataDir, 'tony-menu.sqlite'); +const bucketDir = process.env.BUCKET_DIR || join(dataDir, 'bucket'); + +const sqlite = openSelfHostSqlite(dbPath); +applyMigrations(sqlite, join(root, 'backend/drizzle')); + +const env: Env = { + ...process.env, + APP_ENV: process.env.APP_ENV || 'production', + SERVICE_NAME: process.env.SERVICE_NAME || 'menu-backend-self-host', + COMMIT_SHA: process.env.COMMIT_SHA || 'self-host', + ORDER_TIME_ZONE: process.env.ORDER_TIME_ZONE || 'UTC', + SELF_HOST_AUTH_HEADER: process.env.SELF_HOST_AUTH_HEADER || 'x-forwarded-email', + SQLITE_DB: sqlite, + DB: createD1Compat(sqlite), + PUBLIC_MENU_BUCKET: createFsBucket(bucketDir), + R2_PUBLIC_URL: process.env.R2_PUBLIC_URL || (process.env.PUBLIC_URL ? `${process.env.PUBLIC_URL.replace(/\/$/, '')}/assets` : ''), +}; + +const app = createApp(); +const waitUntil = (promise: Promise) => void promise.catch((error) => console.error('[waitUntil]', error)); + +async function serveAsset(request: Request): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith('/assets/')) return null; + const key = decodeURIComponent(url.pathname.slice('/assets/'.length)); + const object = await env.PUBLIC_MENU_BUCKET?.get(key); + if (!object) return new Response('Not Found', { status: 404 }); + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('cache-control', 'public, max-age=31536000, immutable'); + return new Response(await object.arrayBuffer(), { headers }); +} + +async function fetch(request: Request) { + const asset = await serveAsset(request); + if (asset) return asset; + return app.fetch(request, env, { waitUntil, passThroughOnException() {}, props: {} }); +} + +serve({ port, fetch }); +console.log(`TonyMenu backend self-host listening on :${port}`); +console.log(`SQLite: ${dbPath}`); +console.log(`Bucket: ${bucketDir}`); + +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/backend/src/self-host/sqlite.ts b/backend/src/self-host/sqlite.ts new file mode 100644 index 0000000..2385be6 --- /dev/null +++ b/backend/src/self-host/sqlite.ts @@ -0,0 +1,73 @@ +import { readFileSync, readdirSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import Database from 'better-sqlite3'; + +export type SqliteDatabase = Database.Database; + +export function openSelfHostSqlite(path: string): SqliteDatabase { + mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + return db; +} + +export function applyMigrations(db: SqliteDatabase, migrationsDir: string): void { + db.exec('CREATE TABLE IF NOT EXISTS __tony_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)'); + const applied = new Set(db.prepare('SELECT name FROM __tony_migrations').all().map((row) => (row as { name: string }).name)); + const files = readdirSync(migrationsDir).filter((name) => name.endsWith('.sql')).sort(); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = readFileSync(join(migrationsDir, file), 'utf8') + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter(Boolean) + .join(';\n'); + db.transaction(() => { + db.exec(sql); + db.prepare('INSERT INTO __tony_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now()); + })(); + } +} + +export function createD1Compat(db: SqliteDatabase): D1Database { + return { + prepare(sql: string) { + let params: unknown[] = []; + const statement = db.prepare(sql); + return { + bind(...values: unknown[]) { + params = values; + return this; + }, + async first(colName?: string) { + const row = statement.get(...params) as T | undefined; + if (colName && row && typeof row === 'object') return (row as Record)[colName] as T; + return row ?? null; + }, + async all() { + const results = statement.all(...params) as T[]; + return { results, success: true, meta: {} }; + }, + async run() { + const result = statement.run(...params); + return { success: true, meta: { changes: result.changes, last_row_id: result.lastInsertRowid } }; + }, + async raw() { + return statement.raw().all(...params) as T[]; + }, + }; + }, + async batch(statements: D1PreparedStatement[]) { + return db.transaction(() => statements.map((statement) => statement.run()))(); + }, + async exec(sql: string) { + db.exec(sql); + return { count: 0, duration: 0 }; + }, + async dump() { + return new ArrayBuffer(0); + }, + } as unknown as D1Database; +} diff --git a/backend/src/types.ts b/backend/src/types.ts index eb0c298..14fa954 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,6 +1,7 @@ import type { AuthUser } from './middleware/auth'; import type { StaffSession } from './lib/staff'; import type { createDb } from './db/index'; +import type { SqliteDatabase } from './self-host/sqlite'; export interface Env { APP_ENV?: string; @@ -10,6 +11,7 @@ export interface Env { ALLOWED_ORIGINS?: string; ALLOWED_HOST_SUFFIXES?: string; DB?: D1Database; + SQLITE_DB?: SqliteDatabase; PUBLIC_MENU_BUCKET?: R2Bucket; R2_PUBLIC_URL?: string; ACCESS_TEAM_DOMAIN?: string; @@ -20,6 +22,7 @@ export interface Env { DEMO_MODE?: string; E2E_MODE?: string; ORDER_TIME_ZONE?: string; + SELF_HOST_AUTH_HEADER?: string; } export type AppEnvironment = 'development' | 'staging' | 'production'; @@ -30,12 +33,14 @@ export interface RuntimeConfig { serviceName: string; commitSha: string; orderTimeZone: string; - databaseMode: 'd1' | 'unconfigured'; + databaseMode: 'd1' | 'sqlite' | 'unconfigured'; hasPublicMenuBucket: boolean; auth: { issuer?: string; audience?: string; configured: boolean; + mode: 'cloudflare-access' | 'trusted-header' | 'unconfigured'; + trustedHeader?: string; }; } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6195397 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + web: + build: . + command: node scripts/serve-static.mjs web/out 3000 + restart: unless-stopped + + backend: + build: . + command: npm --workspace backend run start:self-host + environment: + PORT: 8787 + DATA_DIR: /data + SQLITE_PATH: /data/tony-menu.sqlite + BUCKET_DIR: /data/bucket + R2_PUBLIC_URL: ${PUBLIC_URL:?Set PUBLIC_URL}/assets + SELF_HOST_AUTH_HEADER: x-forwarded-email + ADMIN_EMAILS: ${ADMIN_EMAILS:-admin@example.com} + ORDER_TIME_ZONE: ${ORDER_TIME_ZONE:-UTC} + ALLOWED_ORIGINS: ${PUBLIC_URL:?Set PUBLIC_URL} + ALLOWED_HOST_SUFFIXES: ${ALLOWED_HOST_SUFFIXES:-localhost} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + volumes: + - tony-data:/data + restart: unless-stopped + + chat: + build: . + command: sh -c "cd web/workers/chat && npm run start:self-host" + environment: + PORT: 8788 + DATA_DIR: /data + SQLITE_PATH: /data/tony-menu.sqlite + CHAT_SESSION_SECRET: ${CHAT_SESSION_SECRET:-change-me} + REFRESH_SECRET: ${REFRESH_SECRET:-change-me} + LLM_PROVIDER: ${LLM_PROVIDER:-openai} + LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + DAILY_AI_REQUEST_LIMIT: ${DAILY_AI_REQUEST_LIMIT:-} + volumes: + - tony-data:/data + restart: unless-stopped + + proxy: + image: caddy:2-alpine + ports: + - "8080:80" + environment: + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} + ADMIN_PASSWORD_HASH: "${ADMIN_PASSWORD_HASH:?Set ADMIN_PASSWORD_HASH. Generate with docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password'}" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + depends_on: + - web + - backend + - chat + restart: unless-stopped + +volumes: + tony-data: diff --git a/docs/secrets-and-env-vars.md b/docs/secrets-and-env-vars.md index 3c9f63f..899aac3 100644 --- a/docs/secrets-and-env-vars.md +++ b/docs/secrets-and-env-vars.md @@ -69,6 +69,20 @@ session tokens (`POST /session`) gated by Cloudflare IP rate-limit. --- +## Docker self-host (no Cloudflare) + +Copy `.env.self-host.example` to `.env` and set: + +| Variable | Required | Notes | +|---|---:|---| +| `PUBLIC_URL` | Yes | Public URL for CORS and uploaded image links. | +| `ADMIN_PASSWORD_HASH` | Yes | Caddy basic-auth hash. Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password'`. | +| `ADMIN_EMAIL`, `ADMIN_EMAILS` | Yes | Caddy forwards `ADMIN_EMAIL`; backend allows emails in `ADMIN_EMAILS`. | +| `CHAT_SESSION_SECRET`, `REFRESH_SECRET` | Yes | Change before public deploy. | +| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` | Optional | Required only for AI chat/translation. | + +See [Self-host without Cloudflare](self-hosting-without-cloudflare.md). + ## Frontend — Cloudflare Pages (`menu` by default) Set in Pages dashboard → Settings → Environment variables (production) and diff --git a/docs/self-hosting-without-cloudflare.md b/docs/self-hosting-without-cloudflare.md new file mode 100644 index 0000000..5057ec7 --- /dev/null +++ b/docs/self-hosting-without-cloudflare.md @@ -0,0 +1,83 @@ +# Self-host without Cloudflare + +This is the provider-independent deploy path for TonyMenu: Docker, Node, SQLite, local file storage, and Caddy. Cloudflare remains the recommended hosted path, but it is not required. + +## What runs + +| Service | Purpose | +|---|---| +| `proxy` | Caddy entrypoint on port 8080 | +| `web` | static Next.js export | +| `backend` | Hono API on Node | +| `chat` | chat worker on Node with in-memory cache/daily cap | +| `tony-data` | SQLite DB + uploaded assets | + +Admin auth is delegated to the reverse proxy. The backend trusts `X-Forwarded-Email`, so never expose `backend:8787` directly to the internet. + +## Quick start + +```bash +cp .env.self-host.example .env + +# Required by Caddy basic auth. Replace the plaintext password. +docker run --rm caddy:2-alpine caddy hash-password --plaintext 'change-this-password' +# paste the output into ADMIN_PASSWORD_HASH in .env + +docker compose up --build +``` + +Open the proxy on port 8080. + +Admin is at `/admin`. Caddy protects `/admin*`, `/api/admin*`, and `/api/catalog/publish` with basic auth, then injects `X-Forwarded-Email` before proxying those API requests to the backend. + +## Environment + +Create `.env` from this template: + +```bash +PUBLIC_URL= +ADMIN_USER=admin +ADMIN_EMAIL=admin@example.com +ADMIN_EMAILS=admin@example.com +ADMIN_PASSWORD_HASH= +ORDER_TIME_ZONE=UTC +ALLOWED_HOST_SUFFIXES=localhost + +# Optional AI chat. Leave empty to disable provider calls until configured. +LLM_PROVIDER=openai +LLM_MODEL=gpt-4o-mini +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +DAILY_AI_REQUEST_LIMIT= + +# Change these before public deploy. +CHAT_SESSION_SECRET=change-me +REFRESH_SECRET=change-me +``` + +`PUBLIC_URL` is used for uploaded image URLs (`/assets/...`). Set it to the public origin users open in their browser before uploading images. + +## Data and backups + +Data lives in the Docker volume `tony-data`: + +- `/data/tony-menu.sqlite` — SQLite database +- `/data/bucket` — uploaded images and catalog assets + +The chat worker's menu cache and daily AI cap are in-memory. They reset when the chat process restarts. + +Backup: + +```bash +docker compose exec backend sh -lc 'cp /data/tony-menu.sqlite /data/tony-menu.sqlite.bak' +docker run --rm -v risto-menu_tony-data:/data -v "$PWD":/backup alpine tar czf /backup/tony-data.tgz /data +``` + +Restore by stopping the stack and replacing the volume contents. + +## Production notes + +- Put TLS in front of Caddy or change `Caddyfile` to use your real site address. +- Keep `backend` and `chat` private. Only expose `proxy`. +- If you already use Authelia, Authentik, oauth2-proxy, or another SSO proxy, replace Caddy basic auth and keep forwarding a trusted email header as `X-Forwarded-Email`. +- SQLite is the supported non-Cloudflare database for this path. Add Postgres only if SQLite becomes a real limit. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index d1d02d0..bfd3e95 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,8 +1,10 @@ -# Self-hosting TonyMenu +# Self-hosting TonyMenu on Cloudflare This guide walks through deploying your own copy on Cloudflare. Everything here is free-tier-friendly; you only pay if traffic or AI chat usage grows. +Want to run without a Cloudflare account? Use [Self-host without Cloudflare](self-hosting-without-cloudflare.md). + --- ## What you'll provision diff --git a/package-lock.json b/package-lock.json index 3d45da7..63789e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "backend": { "version": "0.1.0", "dependencies": { + "@hono/node-server": "^1.19.14", "@menu/schemas": "*", "@types/better-sqlite3": "^7.6.13", "better-sqlite3": "^12.9.0", @@ -561,13 +562,6 @@ "version": "0.0.0", "license": "MIT" }, - "backend/node_modules/hono": { - "version": "4.12.12", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "backend/node_modules/html-escaper": { "version": "2.0.2", "dev": true, @@ -3051,6 +3045,18 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "license": "MIT", @@ -7814,6 +7820,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "dev": true, diff --git a/package.json b/package.json index 5bc82e4..00af0eb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "health": "npm --workspace web run lint && npm --workspace web run test:run && npm --workspace backend run check && npm --workspace backend run test:run && npm --workspace backend run test:migration-sql && cd web/workers/chat && npm run test:run", "initialize": "node scripts/initialize.mjs", "config:generate": "node scripts/initialize.mjs --generate", - "verify:config": "node scripts/verify-config.mjs" + "verify:config": "node scripts/verify-config.mjs", + "backend:self-host": "npm --workspace backend run start:self-host", + "chat:self-host": "cd web/workers/chat && npm run start:self-host", + "web:self-host": "NEXT_IGNORE_INCORRECT_LOCKFILE=1 npm --workspace web run build && node scripts/serve-static.mjs web/out 3000" }, "workspaces": [ "backend", diff --git a/scripts/serve-static.mjs b/scripts/serve-static.mjs new file mode 100644 index 0000000..43b0192 --- /dev/null +++ b/scripts/serve-static.mjs @@ -0,0 +1,38 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize, sep } from 'node:path'; + +const root = normalize(join(process.cwd(), process.argv[2] || 'web/out')); +const port = Number(process.argv[3] || process.env.PORT || 3000); +const types = new Map([ + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.svg', 'image/svg+xml'], + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.webp', 'image/webp'], + ['.ico', 'image/x-icon'], +]); + +function resolvePath(url) { + const pathname = decodeURIComponent(new URL(url, 'http://localhost').pathname); + let full = normalize(join(root, pathname)); + if (!full.startsWith(root + sep) && full !== root) return null; + if (existsSync(full) && statSync(full).isDirectory()) full = join(full, 'index.html'); + if (!existsSync(full) && existsSync(`${full}.html`)) full = `${full}.html`; + return full; +} + +createServer((req, res) => { + const full = resolvePath(req.url || '/'); + if (!full || !existsSync(full) || !statSync(full).isFile()) { + res.writeHead(404).end('Not Found'); + return; + } + res.writeHead(200, { 'content-type': types.get(extname(full)) || 'application/octet-stream' }); + if (req.method === 'HEAD') res.end(); + else createReadStream(full).pipe(res); +}).listen(port, () => console.log(`TonyMenu web listening on :${port}`)); diff --git a/web/workers/chat/package-lock.json b/web/workers/chat/package-lock.json index 555be74..d81f289 100644 --- a/web/workers/chat/package-lock.json +++ b/web/workers/chat/package-lock.json @@ -9,12 +9,17 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@hono/node-server": "^2.0.8", + "better-sqlite3": "^12.11.1", "openai": "^4.77.0", "tinyld": "^1.3.4" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241230.0", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^26.1.0", "@vitest/coverage-v8": "^2.1.9", + "tsx": "^4.23.0", "typescript": "^5.7.0", "vitest": "^2.1.9", "wrangler": "^4.53.0" @@ -60,6 +65,21 @@ "node-fetch": "^2.6.7" } }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -701,6 +721,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.8.tgz", + "integrity": "sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1679,6 +1711,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1687,12 +1729,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~8.3.0" } }, "node_modules/@types/node-fetch": { @@ -1941,6 +1983,60 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1961,6 +2057,30 @@ "node": "18 || 20 || >=22" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2011,6 +2131,12 @@ "node": ">= 16" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2090,6 +2216,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2100,6 +2241,15 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2113,7 +2263,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2147,6 +2296,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -2270,6 +2428,15 @@ "node": ">=6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2280,6 +2447,12 @@ "node": ">=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2332,6 +2505,12 @@ "node": ">= 12.20" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2393,6 +2572,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2509,6 +2694,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2525,6 +2720,38 @@ "ms": "^2.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2728,6 +2955,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/miniflare": { "version": "4.20260301.1", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260301.1.tgz", @@ -2765,6 +3004,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2775,6 +3023,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2800,6 +3054,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -2840,6 +3112,15 @@ } } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openai": { "version": "4.104.0", "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", @@ -2870,6 +3151,21 @@ } } }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2964,6 +3260,72 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -3009,11 +3371,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3110,6 +3491,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3134,6 +3560,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3238,6 +3673,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -3251,6 +3695,34 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -3340,13 +3812,528 @@ "license": "0BSD", "optional": true }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "dev": true, - "license": "Apache-2.0", - "bin": { + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, @@ -3365,9 +4352,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unenv": { @@ -3380,6 +4367,12 @@ "pathe": "^2.0.3" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -4185,6 +5178,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", diff --git a/web/workers/chat/package.json b/web/workers/chat/package.json index 0df5d2e..1b59da2 100644 --- a/web/workers/chat/package.json +++ b/web/workers/chat/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "wrangler dev", + "dev:self-host": "tsx src/self-host/server.ts", + "start:self-host": "tsx src/self-host/server.ts", "deploy": "wrangler deploy", "types": "wrangler types", "test": "vitest", @@ -13,12 +15,17 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@hono/node-server": "^2.0.8", + "better-sqlite3": "^12.11.1", "openai": "^4.77.0", "tinyld": "^1.3.4" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241230.0", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^26.1.0", "@vitest/coverage-v8": "^2.1.9", + "tsx": "^4.23.0", "typescript": "^5.7.0", "vitest": "^2.1.9", "wrangler": "^4.53.0" diff --git a/web/workers/chat/src/chat/handler.ts b/web/workers/chat/src/chat/handler.ts index 20d1f3f..93a2116 100644 --- a/web/workers/chat/src/chat/handler.ts +++ b/web/workers/chat/src/chat/handler.ts @@ -1,4 +1,4 @@ -import type { Env, ChatRequest, ChatToolCall, MenuDataCache } from '../types'; +import type { Env, ChatRequest, ChatToolCall, MenuDataCache, WaitUntilContext } from '../types'; import { getMenuData } from '../menu/cache'; import { buildSystemPrompt } from './system-prompt'; import { TOOLS } from './tools'; @@ -21,10 +21,10 @@ function summarizeToolsForLog(toolCalls: string[]): string { return toolCalls.map(tc => tc.split('(')[0]).join(', '); } -export async function handleChat(request: Request, env: Env, corsHeaders: Record, session: ChatSession, ctx: ExecutionContext): Promise { +export async function handleChat(request: Request, env: Env, corsHeaders: Record, session: ChatSession, ctx: WaitUntilContext): Promise { const startTime = Date.now(); const sessionId = crypto.randomUUID(); - const ip = request.headers.get('cf-connecting-ip') || 'local'; + const ip = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'local'; let body: ChatRequest; try { diff --git a/web/workers/chat/src/index.ts b/web/workers/chat/src/index.ts index 91fac66..36b81c2 100644 --- a/web/workers/chat/src/index.ts +++ b/web/workers/chat/src/index.ts @@ -1,4 +1,4 @@ -import type { Env } from './types'; +import type { Env, WaitUntilContext } from './types'; import { handleChat } from './chat/handler'; import { getCorsHeaders, handleCorsPreFlight } from './middleware/cors'; import { invalidateCache, getMenuData } from './menu/cache'; @@ -14,7 +14,7 @@ function json(data: unknown, status: number, corsHeaders: Record } const worker = { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + async fetch(request: Request, env: Env, ctx: WaitUntilContext): Promise { const url = new URL(request.url); const corsResponse = await handleCorsPreFlight(request, env); @@ -26,7 +26,7 @@ const worker = { // Issuance is gated by Cloudflare IP so opening the modal is cheap but // token churn still has friction. if (url.pathname === '/session' && request.method === 'POST') { - const ip = request.headers.get('cf-connecting-ip') || 'local'; + const ip = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'local'; const rateLimitResp = checkSessionIssueRateLimit(ip); if (rateLimitResp) { return new Response(rateLimitResp.body, { diff --git a/web/workers/chat/src/menu/cache.ts b/web/workers/chat/src/menu/cache.ts index 681d9ca..874529b 100644 --- a/web/workers/chat/src/menu/cache.ts +++ b/web/workers/chat/src/menu/cache.ts @@ -14,22 +14,34 @@ export async function getMenuData(env: Env): Promise { // 1. In-memory (fastest, ~0ms) if (memCache) return memCache; - // 2. KV (~10ms) - const cached = await env.MENU_CACHE.get(CACHE_KEY, 'json'); - if (cached) { - memCache = cached as MenuDataCache; - return memCache; + // 2. KV (~10ms); if KV is down, fall through to D1 instead of crashing chat. + try { + const cached = await env.MENU_CACHE.get(CACHE_KEY, 'json'); + if (cached) { + memCache = cached as MenuDataCache; + return memCache; + } + } catch (error) { + console.warn('[MENU CACHE] get failed:', error); } // 3. D1 (~20ms) — only on cold start or after invalidation const data = await fetchMenuFromD1(env); memCache = data; - await env.MENU_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL }); + try { + await env.MENU_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL }); + } catch (error) { + console.warn('[MENU CACHE] put failed:', error); + } return data; } export async function invalidateCache(env: Env): Promise { memCache = null; - await env.MENU_CACHE.delete(CACHE_KEY); + try { + await env.MENU_CACHE.delete(CACHE_KEY); + } catch (error) { + console.warn('[MENU CACHE] delete failed:', error); + } } diff --git a/web/workers/chat/src/menu/d1.test.ts b/web/workers/chat/src/menu/d1.test.ts index 7e508a5..86a6a7e 100644 --- a/web/workers/chat/src/menu/d1.test.ts +++ b/web/workers/chat/src/menu/d1.test.ts @@ -62,7 +62,7 @@ function makeEnv() { const prepare = vi.fn((sql: string) => ({ first, all, sql })); return { - env: { DB: { prepare } as unknown as D1Database } as Env, + env: { DB: { prepare } as unknown as D1Database } as unknown as Env, prepare, }; } diff --git a/web/workers/chat/src/middleware/cors.ts b/web/workers/chat/src/middleware/cors.ts index 6f1d2b6..f6832c1 100644 --- a/web/workers/chat/src/middleware/cors.ts +++ b/web/workers/chat/src/middleware/cors.ts @@ -1,3 +1,5 @@ +import type { D1DatabaseLike } from '../types'; + const DEV_ALLOWED_ORIGINS = [ 'http://localhost:3000', 'http://localhost:3001', @@ -6,7 +8,7 @@ const DEV_ALLOWED_ORIGINS = [ ]; type CorsEnv = { - DB?: D1Database; + DB?: D1DatabaseLike; ALLOWED_ORIGINS?: string; ALLOWED_HOST_SUFFIXES?: string; }; @@ -38,7 +40,7 @@ function isStandardAllowedOrigin(origin: string, parsed: URL, env: CorsEnv): boo * Querying by hostname keeps CORS in sync with the same association used by * /catalog/resolve-domain, and avoids hard-coding every tenant domain here. */ -async function isVerifiedRestaurantDomain(db: D1Database | undefined, hostname: string): Promise { +async function isVerifiedRestaurantDomain(db: D1DatabaseLike | undefined, hostname: string): Promise { if (!db) return false; const row = await db .prepare('SELECT restaurant_id FROM restaurant_domains WHERE domain = ? AND verified = 1 LIMIT 1') diff --git a/web/workers/chat/src/middleware/daily-cap.test.ts b/web/workers/chat/src/middleware/daily-cap.test.ts index 7b58988..a682f72 100644 --- a/web/workers/chat/src/middleware/daily-cap.test.ts +++ b/web/workers/chat/src/middleware/daily-cap.test.ts @@ -11,7 +11,7 @@ function makeEnv(limit?: string): Env { put: vi.fn(async (key: string, value: string) => { store.set(key, value); }), delete: vi.fn(async (key: string) => { store.delete(key); }), } as unknown as KVNamespace, - } as Env; + } as unknown as Env; } beforeEach(() => vi.restoreAllMocks()); diff --git a/web/workers/chat/src/middleware/daily-cap.ts b/web/workers/chat/src/middleware/daily-cap.ts index 8641869..2658676 100644 --- a/web/workers/chat/src/middleware/daily-cap.ts +++ b/web/workers/chat/src/middleware/daily-cap.ts @@ -27,19 +27,19 @@ export async function consumeDailyAiRequest(env: Env, now = new Date()): Promise if (!limit) return { allowed: true, limit: null, used: 0 }; const key = todayUtcKey(now); - const currentRaw = await env.MENU_CACHE.get(key); - const current = currentRaw ? Number(currentRaw) || 0 : 0; - if (current >= limit) return { allowed: false, limit, used: current }; - - const next = current + 1; - await env.MENU_CACHE.put(key, String(next), { - expirationTtl: secondsUntilTomorrow(now) + 86_400, - }); - - // ponytail: this read-modify-write is NOT atomic. KV offers no atomic increment, so - // concurrent requests can read the same `current` and both write `current+1`, letting a - // burst slightly exceed `limit` (and under-count the stored total). Ceiling: over-admission - // bounded by concurrency, which is acceptable for a soft daily cap. Upgrade path: move the - // counter into a Durable Object (single-threaded, true atomic increment) if the cap must be hard. - return { allowed: true, limit, used: next }; + try { + const currentRaw = await env.MENU_CACHE.get(key); + const current = currentRaw ? Number(currentRaw) || 0 : 0; + if (current >= limit) return { allowed: false, limit, used: current }; + + const next = current + 1; + // ponytail: this read-modify-write is a soft cap; use Redis/DB atomic increment if it must be hard. + await env.MENU_CACHE.put(key, String(next), { + expirationTtl: secondsUntilTomorrow(now) + 86_400, + }); + return { allowed: true, limit, used: next }; + } catch (error) { + console.warn('[DAILY CAP] unavailable:', error); + return { allowed: true, limit, used: 0 }; + } } diff --git a/web/workers/chat/src/middleware/menu-guard.ts b/web/workers/chat/src/middleware/menu-guard.ts index 3631682..8453a32 100644 --- a/web/workers/chat/src/middleware/menu-guard.ts +++ b/web/workers/chat/src/middleware/menu-guard.ts @@ -1,3 +1,5 @@ +import type { D1DatabaseLike } from '../types'; + export type MenuGuardResult = 'ok' | 'draft' | 'chat_disabled'; /** @@ -5,7 +7,8 @@ export type MenuGuardResult = 'ok' | 'draft' | 'chat_disabled'; * Direct D1 query — bypasses menu cache intentionally so disabling chat takes * effect immediately. */ -export async function checkMenuForChat(db: D1Database): Promise { + +export async function checkMenuForChat(db: D1DatabaseLike): Promise { const row = await db .prepare('SELECT publication_state, ai_chat_enabled FROM settings WHERE id = 1') .first<{ publication_state: string; ai_chat_enabled: number }>(); diff --git a/web/workers/chat/src/self-host/server.ts b/web/workers/chat/src/self-host/server.ts new file mode 100644 index 0000000..61357cc --- /dev/null +++ b/web/workers/chat/src/self-host/server.ts @@ -0,0 +1,36 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { serve } from '@hono/node-server'; +import worker from '../index'; +import type { Env, WaitUntilContext } from '../types'; +import { applyMigrations, createD1Compat, createMemoryKv, openSelfHostSqlite } from './sqlite'; + +const root = fileURLToPath(new URL('../../../../..', import.meta.url).href); +const port = Number(process.env.CHAT_PORT || process.env.PORT || 8788); +const dataDir = process.env.DATA_DIR || join(root, '.self-host'); +const dbPath = process.env.SQLITE_PATH || join(dataDir, 'tony-menu.sqlite'); + +const sqlite = openSelfHostSqlite(dbPath); +applyMigrations(sqlite, join(root, 'backend/drizzle')); + +const env: Env = { + ...process.env, + DB: createD1Compat(sqlite), + MENU_CACHE: createMemoryKv(), + CHAT_SESSION_SECRET: process.env.CHAT_SESSION_SECRET || 'dev-change-me', + REFRESH_SECRET: process.env.REFRESH_SECRET || 'dev-refresh-secret', + LLM_PROVIDER: process.env.LLM_PROVIDER || 'anthropic', + LLM_MODEL: process.env.LLM_MODEL || '', + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '', + OPENAI_API_KEY: process.env.OPENAI_API_KEY || '', +}; + +const ctx: WaitUntilContext = { + waitUntil(promise) { + void promise.catch((error) => console.error('[waitUntil]', error)); + }, +}; + +serve({ port, fetch: (request) => worker.fetch(request, env, ctx) }); +console.log(`TonyMenu chat self-host listening on :${port}`); +console.log(`SQLite: ${dbPath}`); diff --git a/web/workers/chat/src/self-host/sqlite.ts b/web/workers/chat/src/self-host/sqlite.ts new file mode 100644 index 0000000..0968b1b --- /dev/null +++ b/web/workers/chat/src/self-host/sqlite.ts @@ -0,0 +1,85 @@ +import { readFileSync, readdirSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import Database from 'better-sqlite3'; +import type { KVNamespaceLike } from '../types'; + +export type SqliteDatabase = Database.Database; + +export function openSelfHostSqlite(path: string): SqliteDatabase { + mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + return db; +} + +export function applyMigrations(db: SqliteDatabase, migrationsDir: string): void { + db.exec('CREATE TABLE IF NOT EXISTS __tony_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)'); + const applied = new Set(db.prepare('SELECT name FROM __tony_migrations').all().map((row) => (row as { name: string }).name)); + const files = readdirSync(migrationsDir).filter((name) => name.endsWith('.sql')).sort(); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = readFileSync(join(migrationsDir, file), 'utf8') + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter(Boolean) + .join(';\n'); + db.transaction(() => { + db.exec(sql); + db.prepare('INSERT INTO __tony_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now()); + })(); + } +} + +export function createD1Compat(db: SqliteDatabase): D1Database { + return { + prepare(sql: string) { + let params: unknown[] = []; + const statement = db.prepare(sql); + return { + bind(...values: unknown[]) { + params = values; + return this; + }, + async first(colName?: string) { + const row = statement.get(...params) as T | undefined; + if (colName && row && typeof row === 'object') return (row as Record)[colName] as T; + return row ?? null; + }, + async all() { + return { results: statement.all(...params) as T[], success: true, meta: {} }; + }, + async run() { + const result = statement.run(...params); + return { success: true, meta: { changes: result.changes, last_row_id: result.lastInsertRowid } }; + }, + }; + }, + } as unknown as D1Database; +} + +export function createMemoryKv(): KVNamespaceLike { + const data = new Map(); + return { + async get(key: string, type?: 'text' | 'json') { + const row = data.get(key); + if (!row) return null; + if (row.expiresAt && row.expiresAt <= Date.now()) { + data.delete(key); + return null; + } + return type === 'json' ? JSON.parse(row.value) : row.value; + }, + async put(key: string, value: string, options?: { expirationTtl?: number }) { + data.set(key, { + value, + expiresAt: options?.expirationTtl ? Date.now() + options.expirationTtl * 1000 : undefined, + }); + }, + async delete(key: string) { + data.delete(key); + }, + }; +} + diff --git a/web/workers/chat/src/types.ts b/web/workers/chat/src/types.ts index bb60901..e7c7111 100644 --- a/web/workers/chat/src/types.ts +++ b/web/workers/chat/src/types.ts @@ -1,6 +1,15 @@ +export type WaitUntilContext = Pick; +export type D1DatabaseLike = Pick; +export interface KVNamespaceLike { + get(key: string, type?: 'text'): Promise; + get(key: string, type: 'json'): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; + delete(key: string): Promise; +} + export interface Env { - MENU_CACHE: KVNamespace; - DB: D1Database; + MENU_CACHE: KVNamespaceLike; + DB: D1DatabaseLike; CHAT_SESSION_SECRET: string; LLM_PROVIDER: string; LLM_MODEL: string; From 041bd4dcdcabb167ea449031526a44d3a275cf17 Mon Sep 17 00:00:00 2001 From: Andrea Baccega Date: Tue, 7 Jul 2026 23:01:08 +0200 Subject: [PATCH 3/4] fix(self-host): strip trusted header on public routes, content-type for assets, remove broken batch shim --- Caddyfile | 20 ++++++++++++++++---- backend/src/db/index.ts | 13 ------------- backend/src/lib/env.ts | 2 +- backend/src/self-host/fs-bucket.ts | 4 +++- backend/src/self-host/server.ts | 1 - backend/src/types.ts | 2 -- 6 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Caddyfile b/Caddyfile index 530d660..8d64f51 100644 --- a/Caddyfile +++ b/Caddyfile @@ -22,19 +22,31 @@ handle /api/* { uri strip_prefix /api - reverse_proxy backend:8787 + reverse_proxy backend:8787 { + header_up X-Forwarded-Email "" + header_up X-Forwarded-Name "" + } } handle /chat/* { uri strip_prefix /chat - reverse_proxy chat:8788 + reverse_proxy chat:8788 { + header_up X-Forwarded-Email "" + header_up X-Forwarded-Name "" + } } handle /assets/* { - reverse_proxy backend:8787 + reverse_proxy backend:8787 { + header_up X-Forwarded-Email "" + header_up X-Forwarded-Name "" + } } handle { - reverse_proxy web:3000 + reverse_proxy web:3000 { + header_up X-Forwarded-Email "" + header_up X-Forwarded-Name "" + } } } diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index d0af608..d2ce0e6 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,22 +1,9 @@ import { drizzle as drizzleD1 } from 'drizzle-orm/d1'; -import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3'; import type { Env } from '../types'; type D1Db = ReturnType; -function withBatch(db: object): D1Db { - if ('batch' in db) return db as D1Db; - return Object.assign(db, { - async batch(queries: unknown[]) { - const results: unknown[] = []; - for (const query of queries) results.push(await (query as { execute: () => Promise }).execute()); - return results; - }, - }) as D1Db; -} - export function createDb(env: Env): D1Db | null { - if (env.SQLITE_DB) return withBatch(drizzleSqlite(env.SQLITE_DB)); if (env.DB) return drizzleD1(env.DB); return null; } diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts index a5d1f29..21e85a7 100644 --- a/backend/src/lib/env.ts +++ b/backend/src/lib/env.ts @@ -30,7 +30,7 @@ export function getRuntimeConfig(env: Env): RuntimeConfig { serviceName: parsed.SERVICE_NAME, commitSha: parsed.COMMIT_SHA, orderTimeZone: parsed.ORDER_TIME_ZONE, - databaseMode: env.SQLITE_DB ? 'sqlite' : env.DB ? 'd1' : 'unconfigured', + databaseMode: env.DB ? 'd1' : 'unconfigured', hasPublicMenuBucket: Boolean(env.PUBLIC_MENU_BUCKET), auth: { issuer: parsed.ACCESS_TEAM_DOMAIN, diff --git a/backend/src/self-host/fs-bucket.ts b/backend/src/self-host/fs-bucket.ts index f7afc1e..a91b981 100644 --- a/backend/src/self-host/fs-bucket.ts +++ b/backend/src/self-host/fs-bucket.ts @@ -60,7 +60,9 @@ export function createFsBucket(root: string): R2Bucket { async get(key: string) { try { const data = await readFile(safePath(root, key)); - return object(root, key, data); + const ext = key.slice(key.lastIndexOf('.')).toLowerCase(); + const contentType = ({ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.json': 'application/json', '.pdf': 'application/pdf' } as Record)[ext]; + return object(root, key, data, contentType); } catch { return null; } diff --git a/backend/src/self-host/server.ts b/backend/src/self-host/server.ts index 2dbe9bc..03c1cc5 100644 --- a/backend/src/self-host/server.ts +++ b/backend/src/self-host/server.ts @@ -22,7 +22,6 @@ const env: Env = { COMMIT_SHA: process.env.COMMIT_SHA || 'self-host', ORDER_TIME_ZONE: process.env.ORDER_TIME_ZONE || 'UTC', SELF_HOST_AUTH_HEADER: process.env.SELF_HOST_AUTH_HEADER || 'x-forwarded-email', - SQLITE_DB: sqlite, DB: createD1Compat(sqlite), PUBLIC_MENU_BUCKET: createFsBucket(bucketDir), R2_PUBLIC_URL: process.env.R2_PUBLIC_URL || (process.env.PUBLIC_URL ? `${process.env.PUBLIC_URL.replace(/\/$/, '')}/assets` : ''), diff --git a/backend/src/types.ts b/backend/src/types.ts index 14fa954..c0894bf 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,7 +1,6 @@ import type { AuthUser } from './middleware/auth'; import type { StaffSession } from './lib/staff'; import type { createDb } from './db/index'; -import type { SqliteDatabase } from './self-host/sqlite'; export interface Env { APP_ENV?: string; @@ -11,7 +10,6 @@ export interface Env { ALLOWED_ORIGINS?: string; ALLOWED_HOST_SUFFIXES?: string; DB?: D1Database; - SQLITE_DB?: SqliteDatabase; PUBLIC_MENU_BUCKET?: R2Bucket; R2_PUBLIC_URL?: string; ACCESS_TEAM_DOMAIN?: string; From dbd1177570726b099153ede25e901c89693662ce Mon Sep 17 00:00:00 2001 From: Andrea Baccega Date: Wed, 8 Jul 2026 10:51:50 +0200 Subject: [PATCH 4/4] fix(self-host): harden sqlite and deployment defaults --- .env.self-host.example | 4 +- backend/package.json | 2 +- .../src/__tests__/self-host-sqlite.test.ts | 64 +++++++++++++++++++ backend/src/self-host/server.ts | 2 +- backend/src/self-host/sqlite.ts | 22 +++++-- docker-compose.yml | 4 +- docs/self-hosting-without-cloudflare.md | 6 +- package.json | 2 +- scripts/serve-static.mjs | 17 ++++- scripts/serve-static.test.mjs | 46 +++++++++++++ web/workers/chat/src/self-host/server.ts | 9 ++- web/workers/chat/src/self-host/sqlite.test.ts | 50 +++++++++++++++ web/workers/chat/src/self-host/sqlite.ts | 11 +++- 13 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 backend/src/__tests__/self-host-sqlite.test.ts create mode 100644 scripts/serve-static.test.mjs create mode 100644 web/workers/chat/src/self-host/sqlite.test.ts diff --git a/.env.self-host.example b/.env.self-host.example index fda03c5..ef0b736 100644 --- a/.env.self-host.example +++ b/.env.self-host.example @@ -12,5 +12,5 @@ OPENAI_API_KEY= ANTHROPIC_API_KEY= DAILY_AI_REQUEST_LIMIT= -CHAT_SESSION_SECRET=change-me -REFRESH_SECRET=change-me +CHAT_SESSION_SECRET= +REFRESH_SECRET= diff --git a/backend/package.json b/backend/package.json index fc3b31b..003e2b5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "dev": "wrangler dev --persist-to .wrangler/state", "dev:self-host": "tsx src/self-host/server.ts", "start:self-host": "tsx src/self-host/server.ts", - "deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)} --var ORDER_TIME_ZONE:${ORDER_TIME_ZONE:-$(node -e \"console.log(require('../.tony-menu.local.json').backend.orderTimeZone || 'UTC')\")}", + "deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)}${ORDER_TIME_ZONE:+ --var ORDER_TIME_ZONE:$ORDER_TIME_ZONE}", "check": "tsc --noEmit", "typegen": "wrangler types", "import:backup": "tsx scripts/import-from-backup.ts", diff --git a/backend/src/__tests__/self-host-sqlite.test.ts b/backend/src/__tests__/self-host-sqlite.test.ts new file mode 100644 index 0000000..ee71fb3 --- /dev/null +++ b/backend/src/__tests__/self-host-sqlite.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { applyMigrations, createD1Compat } from '../self-host/sqlite'; + +describe('self-host sqlite D1 compat', () => { + it('rolls back batch failures', async () => { + const d1 = createD1Compat(new Database(':memory:')); + await d1.exec('CREATE TABLE items (id TEXT PRIMARY KEY)'); + + await expect( + d1.batch([ + d1.prepare('INSERT INTO items (id) VALUES (?)').bind('a'), + d1.prepare('INSERT INTO items (id) VALUES (?)').bind('a'), + ]), + ).rejects.toThrow(); + + await expect(d1.prepare('SELECT COUNT(*) AS count FROM items').first('count')).resolves.toBe(0); + }); + + it('runs foreign-key-disabling migrations outside a transaction', () => { + const dir = mkdtempSync(join(tmpdir(), 'tony-migrations-')); + writeFileSync( + join(dir, '0000_init.sql'), + `CREATE TABLE parent (id TEXT PRIMARY KEY);--> statement-breakpoint +CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT REFERENCES parent(id) ON DELETE CASCADE);--> statement-breakpoint +INSERT INTO parent (id) VALUES ('p1');--> statement-breakpoint +INSERT INTO child (id, parent_id) VALUES ('c1', 'p1');`, + ); + writeFileSync( + join(dir, '0001_recreate_parent.sql'), + `PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE new_parent (id TEXT PRIMARY KEY);--> statement-breakpoint +INSERT INTO new_parent SELECT id FROM parent;--> statement-breakpoint +DROP TABLE parent;--> statement-breakpoint +ALTER TABLE new_parent RENAME TO parent;--> statement-breakpoint +PRAGMA foreign_keys=ON;`, + ); + + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + applyMigrations(db, dir); + + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toEqual({ count: 1 }); + }); + + it('restores foreign keys when a non-transactional migration fails', () => { + const dir = mkdtempSync(join(tmpdir(), 'tony-bad-migrations-')); + writeFileSync( + join(dir, '0000_bad.sql'), + `PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE parent (id TEXT PRIMARY KEY);--> statement-breakpoint +BROKEN SQL;`, + ); + + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + + expect(() => applyMigrations(db, dir)).toThrow(); + expect(db.pragma('foreign_keys', { simple: true })).toBe(1); + }); +}); diff --git a/backend/src/self-host/server.ts b/backend/src/self-host/server.ts index 03c1cc5..576afd8 100644 --- a/backend/src/self-host/server.ts +++ b/backend/src/self-host/server.ts @@ -21,7 +21,7 @@ const env: Env = { SERVICE_NAME: process.env.SERVICE_NAME || 'menu-backend-self-host', COMMIT_SHA: process.env.COMMIT_SHA || 'self-host', ORDER_TIME_ZONE: process.env.ORDER_TIME_ZONE || 'UTC', - SELF_HOST_AUTH_HEADER: process.env.SELF_HOST_AUTH_HEADER || 'x-forwarded-email', + SELF_HOST_AUTH_HEADER: process.env.SELF_HOST_AUTH_HEADER, DB: createD1Compat(sqlite), PUBLIC_MENU_BUCKET: createFsBucket(bucketDir), R2_PUBLIC_URL: process.env.R2_PUBLIC_URL || (process.env.PUBLIC_URL ? `${process.env.PUBLIC_URL.replace(/\/$/, '')}/assets` : ''), diff --git a/backend/src/self-host/sqlite.ts b/backend/src/self-host/sqlite.ts index 2385be6..2ebce5e 100644 --- a/backend/src/self-host/sqlite.ts +++ b/backend/src/self-host/sqlite.ts @@ -4,6 +4,10 @@ import Database from 'better-sqlite3'; export type SqliteDatabase = Database.Database; +type SelfHostD1PreparedStatement = D1PreparedStatement & { + runSync(): D1Result; +}; + export function openSelfHostSqlite(path: string): SqliteDatabase { mkdirSync(dirname(path), { recursive: true }); const db = new Database(path); @@ -24,10 +28,17 @@ export function applyMigrations(db: SqliteDatabase, migrationsDir: string): void .map((statement) => statement.trim()) .filter(Boolean) .join(';\n'); - db.transaction(() => { + const apply = () => { db.exec(sql); db.prepare('INSERT INTO __tony_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now()); - })(); + }; + if (/PRAGMA\s+foreign_keys\s*=\s*OFF/i.test(sql)) { + try { + apply(); + } finally { + db.pragma('foreign_keys = ON'); + } + } else db.transaction(apply)(); } } @@ -50,17 +61,20 @@ export function createD1Compat(db: SqliteDatabase): D1Database { const results = statement.all(...params) as T[]; return { results, success: true, meta: {} }; }, - async run() { + runSync() { const result = statement.run(...params); return { success: true, meta: { changes: result.changes, last_row_id: result.lastInsertRowid } }; }, + async run() { + return this.runSync(); + }, async raw() { return statement.raw().all(...params) as T[]; }, }; }, async batch(statements: D1PreparedStatement[]) { - return db.transaction(() => statements.map((statement) => statement.run()))(); + return db.transaction(() => statements.map((statement) => (statement as SelfHostD1PreparedStatement).runSync()))(); }, async exec(sql: string) { db.exec(sql); diff --git a/docker-compose.yml b/docker-compose.yml index 6195397..750e96d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,8 +30,8 @@ services: PORT: 8788 DATA_DIR: /data SQLITE_PATH: /data/tony-menu.sqlite - CHAT_SESSION_SECRET: ${CHAT_SESSION_SECRET:-change-me} - REFRESH_SECRET: ${REFRESH_SECRET:-change-me} + CHAT_SESSION_SECRET: ${CHAT_SESSION_SECRET:?Set CHAT_SESSION_SECRET} + REFRESH_SECRET: ${REFRESH_SECRET:?Set REFRESH_SECRET} LLM_PROVIDER: ${LLM_PROVIDER:-openai} LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini} OPENAI_API_KEY: ${OPENAI_API_KEY:-} diff --git a/docs/self-hosting-without-cloudflare.md b/docs/self-hosting-without-cloudflare.md index 5057ec7..c584f03 100644 --- a/docs/self-hosting-without-cloudflare.md +++ b/docs/self-hosting-without-cloudflare.md @@ -50,9 +50,9 @@ OPENAI_API_KEY= ANTHROPIC_API_KEY= DAILY_AI_REQUEST_LIMIT= -# Change these before public deploy. -CHAT_SESSION_SECRET=change-me -REFRESH_SECRET=change-me +# Required. Generate random values before public deploy. +CHAT_SESSION_SECRET= +REFRESH_SECRET= ``` `PUBLIC_URL` is used for uploaded image URLs (`/assets/...`). Set it to the public origin users open in their browser before uploading images. diff --git a/package.json b/package.json index 00af0eb..3593444 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tony-menu", "private": true, "scripts": { - "health": "npm --workspace web run lint && npm --workspace web run test:run && npm --workspace backend run check && npm --workspace backend run test:run && npm --workspace backend run test:migration-sql && cd web/workers/chat && npm run test:run", + "health": "npm --workspace web run lint && npm --workspace web run test:run && node --test scripts/serve-static.test.mjs && npm --workspace backend run check && npm --workspace backend run test:run && npm --workspace backend run test:migration-sql && cd web/workers/chat && npm run test:run", "initialize": "node scripts/initialize.mjs", "config:generate": "node scripts/initialize.mjs --generate", "verify:config": "node scripts/verify-config.mjs", diff --git a/scripts/serve-static.mjs b/scripts/serve-static.mjs index 43b0192..73e7e46 100644 --- a/scripts/serve-static.mjs +++ b/scripts/serve-static.mjs @@ -18,7 +18,12 @@ const types = new Map([ ]); function resolvePath(url) { - const pathname = decodeURIComponent(new URL(url, 'http://localhost').pathname); + let pathname; + try { + pathname = decodeURIComponent(new URL(url, 'http://localhost').pathname); + } catch { + return null; + } let full = normalize(join(root, pathname)); if (!full.startsWith(root + sep) && full !== root) return null; if (existsSync(full) && statSync(full).isDirectory()) full = join(full, 'index.html'); @@ -26,7 +31,7 @@ function resolvePath(url) { return full; } -createServer((req, res) => { +const server = createServer((req, res) => { const full = resolvePath(req.url || '/'); if (!full || !existsSync(full) || !statSync(full).isFile()) { res.writeHead(404).end('Not Found'); @@ -35,4 +40,10 @@ createServer((req, res) => { res.writeHead(200, { 'content-type': types.get(extname(full)) || 'application/octet-stream' }); if (req.method === 'HEAD') res.end(); else createReadStream(full).pipe(res); -}).listen(port, () => console.log(`TonyMenu web listening on :${port}`)); +}); + +server.listen(port, () => { + const address = server.address(); + const actualPort = typeof address === 'object' && address ? address.port : port; + console.log(`TonyMenu web listening on :${actualPort}`); +}); diff --git a/scripts/serve-static.test.mjs b/scripts/serve-static.test.mjs new file mode 100644 index 0000000..a55482e --- /dev/null +++ b/scripts/serve-static.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(new URL('./serve-static.mjs', import.meta.url)); + +function request(port, path) { + return new Promise((resolve, reject) => { + http + .get({ port, path }, (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }) + .on('error', reject); + }); +} +async function startedPort(server) { + return await new Promise((resolve, reject) => { + server.once('error', reject); + server.once('exit', () => reject(new Error('server exited before accepting requests'))); + server.stdout.once('data', (chunk) => { + const match = String(chunk).match(/:(\d+)/); + if (!match) reject(new Error('server did not print a port')); + else resolve(Number(match[1])); + }); + }); +} + +test('malformed percent-encoded URL returns 404 instead of crashing', async () => { + const dir = mkdtempSync(join(tmpdir(), 'tony-static-')); + writeFileSync(join(dir, 'index.html'), 'ok'); + const server = spawn(process.execPath, [scriptPath, dir, '0']); + const port = await startedPort(server); + + try { + assert.equal(await request(port, '/%E0%A4%A'), 404); + assert.equal(server.exitCode, null); + } finally { + server.kill(); + } +}); diff --git a/web/workers/chat/src/self-host/server.ts b/web/workers/chat/src/self-host/server.ts index 61357cc..32f756e 100644 --- a/web/workers/chat/src/self-host/server.ts +++ b/web/workers/chat/src/self-host/server.ts @@ -10,6 +10,11 @@ const port = Number(process.env.CHAT_PORT || process.env.PORT || 8788); const dataDir = process.env.DATA_DIR || join(root, '.self-host'); const dbPath = process.env.SQLITE_PATH || join(dataDir, 'tony-menu.sqlite'); +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} const sqlite = openSelfHostSqlite(dbPath); applyMigrations(sqlite, join(root, 'backend/drizzle')); @@ -17,8 +22,8 @@ const env: Env = { ...process.env, DB: createD1Compat(sqlite), MENU_CACHE: createMemoryKv(), - CHAT_SESSION_SECRET: process.env.CHAT_SESSION_SECRET || 'dev-change-me', - REFRESH_SECRET: process.env.REFRESH_SECRET || 'dev-refresh-secret', + CHAT_SESSION_SECRET: requiredEnv('CHAT_SESSION_SECRET'), + REFRESH_SECRET: requiredEnv('REFRESH_SECRET'), LLM_PROVIDER: process.env.LLM_PROVIDER || 'anthropic', LLM_MODEL: process.env.LLM_MODEL || '', ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '', diff --git a/web/workers/chat/src/self-host/sqlite.test.ts b/web/workers/chat/src/self-host/sqlite.test.ts new file mode 100644 index 0000000..f871fb1 --- /dev/null +++ b/web/workers/chat/src/self-host/sqlite.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { applyMigrations } from './sqlite'; + +describe('self-host sqlite migrations', () => { + it('runs foreign-key-disabling migrations outside a transaction', () => { + const dir = mkdtempSync(join(tmpdir(), 'tony-chat-migrations-')); + writeFileSync( + join(dir, '0000_init.sql'), + `CREATE TABLE parent (id TEXT PRIMARY KEY);--> statement-breakpoint +CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT REFERENCES parent(id) ON DELETE CASCADE);--> statement-breakpoint +INSERT INTO parent (id) VALUES ('p1');--> statement-breakpoint +INSERT INTO child (id, parent_id) VALUES ('c1', 'p1');`, + ); + writeFileSync( + join(dir, '0001_recreate_parent.sql'), + `PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE new_parent (id TEXT PRIMARY KEY);--> statement-breakpoint +INSERT INTO new_parent SELECT id FROM parent;--> statement-breakpoint +DROP TABLE parent;--> statement-breakpoint +ALTER TABLE new_parent RENAME TO parent;--> statement-breakpoint +PRAGMA foreign_keys=ON;`, + ); + + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + applyMigrations(db, dir); + + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toEqual({ count: 1 }); + }); + + it('restores foreign keys when a non-transactional migration fails', () => { + const dir = mkdtempSync(join(tmpdir(), 'tony-chat-bad-migrations-')); + writeFileSync( + join(dir, '0000_bad.sql'), + `PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE parent (id TEXT PRIMARY KEY);--> statement-breakpoint +BROKEN SQL;`, + ); + + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + + expect(() => applyMigrations(db, dir)).toThrow(); + expect(db.pragma('foreign_keys', { simple: true })).toBe(1); + }); +}); diff --git a/web/workers/chat/src/self-host/sqlite.ts b/web/workers/chat/src/self-host/sqlite.ts index 0968b1b..7facbcd 100644 --- a/web/workers/chat/src/self-host/sqlite.ts +++ b/web/workers/chat/src/self-host/sqlite.ts @@ -25,10 +25,17 @@ export function applyMigrations(db: SqliteDatabase, migrationsDir: string): void .map((statement) => statement.trim()) .filter(Boolean) .join(';\n'); - db.transaction(() => { + const apply = () => { db.exec(sql); db.prepare('INSERT INTO __tony_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now()); - })(); + }; + if (/PRAGMA\s+foreign_keys\s*=\s*OFF/i.test(sql)) { + try { + apply(); + } finally { + db.pragma('foreign_keys = ON'); + } + } else db.transaction(apply)(); } }