Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**/node_modules
**/.next
**/out
**/.wrangler
.worktrees
.self-host
data
.git
.env*
.tony-menu.local.json
16 changes: 16 additions & 0 deletions .env.self-host.example
Original file line number Diff line number Diff line change
@@ -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=
REFRESH_SECRET=
4 changes: 4 additions & 0 deletions .pii-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
: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 {
header_up X-Forwarded-Email ""
header_up X-Forwarded-Name ""
}
}

handle /chat/* {
uri strip_prefix /chat
reverse_proxy chat:8788 {
header_up X-Forwarded-Email ""
header_up X-Forwarded-Name ""
}
}

handle /assets/* {
reverse_proxy backend:8787 {
header_up X-Forwarded-Email ""
header_up X-Forwarded-Name ""
}
}

handle {
reverse_proxy web:3000 {
header_up X-Forwarded-Email ""
header_up X-Forwarded-Name ""
}
}
}
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "wrangler dev --persist-to .wrangler/state",
"deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)}",
"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)}${ORDER_TIME_ZONE:+ --var ORDER_TIME_ZONE:$ORDER_TIME_ZONE}",
"check": "tsc --noEmit",
"typegen": "wrangler types",
"import:backup": "tsx scripts/import-from-backup.ts",
Expand All @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions backend/src/__tests__/self-host-sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 6 additions & 4 deletions backend/src/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { drizzle } from 'drizzle-orm/d1';
import { drizzle as drizzleD1 } from 'drizzle-orm/d1';
import type { Env } from '../types';

export function createDb(env: Env) {
if (!env.DB) return null;
return drizzle(env.DB);
type D1Db = ReturnType<typeof drizzleD1>;

export function createDb(env: Env): D1Db | null {
if (env.DB) return drizzleD1(env.DB);
return null;
}

export type DbInstance = NonNullable<ReturnType<typeof createDb>>;
9 changes: 8 additions & 1 deletion backend/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,7 +35,13 @@ export function getRuntimeConfig(env: Env): RuntimeConfig {
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),
},
};
}
25 changes: 17 additions & 8 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthBindings>(async (c, next) => {
const config = c.get('config');
Expand All @@ -182,6 +178,20 @@ export const requireAuth = createMiddleware<AuthBindings>(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);
Expand All @@ -197,7 +207,6 @@ export const requireAuth = createMiddleware<AuthBindings>(async (c, next) => {
name: payload.name as string | undefined,
claims: payload,
};

c.set('user', user);
await next();
} catch (err) {
Expand Down
6 changes: 3 additions & 3 deletions backend/src/middleware/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ export const requestLogger = createMiddleware<AppBindings>(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.
Expand All @@ -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();
Expand Down
Loading
Loading