Restaurant Menu Domains and QR Implementation Plan
For Codex: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Serve a published menu on a reserved *.dailymark.me hostname or verified custom domain and issue stable campaign-aware QR artifacts.
Architecture: MenuDomain is globally unique but tenant-owned. DNS ownership/routing are verified asynchronously; a narrowly scoped RLS identity policy lets the internal Caddy ask endpoint see only an exact active host. QR campaigns encode a stable host and opaque campaign code, never a publication or tenant ID.
Tech Stack: NestJS, TypeORM/PostgreSQL FORCE RLS, Node dns/promises, BullMQ, Caddy On-Demand TLS/DNS challenge, qrcode, Next.js/Ant Design, Bun test, Playwright.
Global Constraints
- Core publishing is merged and exposes
MenuDomain,ObjectStore,hostManifestKey, and active host manifests. - Normalize all hosts to lower-case ASCII IDNA before lookup, verification, routing, or certificate authorization.
- Deny unknown, pending, disabled, reserved, IP-literal, localhost, public-suffix-only, wildcard, and malformed hosts.
- Caddy
askreturns 2xx only for an exactactivedomain whose publication manifest exists. - The TLS identity policy must not grant general cross-tenant visibility.
- QR URL includes stable host and opaque campaign code, not tenant/publication identifiers.
- Obtain approval before migration, dependency installation, DNS/CI/Caddy changes, certificate issuance, or deployment.
File and Interface Ledger
apps/api/src/menu/domains/hostname.ts:normalizeHostname,assertReservableHostname.apps/api/src/menu/domains/domain.service.ts: reservation and lifecycle.apps/api/src/menu/domains/domain-verifier.ts: DNS verification contract.apps/api/src/menu/domains/domain.processor.ts:menu-domain-verifyworker.apps/api/src/menu/domains/tls-ask.service.ts: exact-host RLS query.apps/api/src/menu/domains/tls-ask.controller.ts: internal Caddy endpoint.apps/api/src/menu/qr/qr-campaign.entity.ts: tenant campaign state.apps/api/src/menu/qr/qr.service.ts: stable URL and SVG/PNG generation.apps/api/src/db/migrations/1723400000000-MenuDomainsQr.ts: identity policy and QR table.
export interface DomainDnsProbe {
txt(name: string): Promise<string[][]>;
cname(name: string): Promise<string[]>;
address(name: string): Promise<{ v4: string[]; v6: string[] }>;
}
export interface DomainVerificationResult {
ownership: 'verified' | 'missing';
routing: 'verified' | 'missing' | 'wrong_target';
evidence: string[];
}
export interface QrCampaignResult {
id: string;
url: string;
svgObjectKey: string;
pngObjectKey: string;
}Task 1: Canonical Hostname Validation and Reservation API
Files:
- Create:
apps/api/src/menu/domains/hostname.ts - Create:
apps/api/src/menu/domains/reserved-hosts.ts - Create:
apps/api/src/menu/domains/domain.dto.ts - Create:
apps/api/src/menu/domains/domain.service.ts - Create:
apps/api/src/menu/domains/domain.controller.ts - Modify:
apps/api/src/menu/menu.module.ts - Test:
apps/api/src/menu/domains/hostname.spec.ts - Test:
apps/api/test/menu-domains.e2e.spec.ts
Interfaces:
Consumes: core
MenuDomain, menu repository,RequestUser.Produces:
/menus/:menuId/domainsCRUD and one canonical hostname algorithm for DNS/TLS/gateway/QR.[ ] Step 1: Write failing hostname table tests
it.each([
['Café.Example', 'xn--caf-dma.example'],
['warm-corner.dailymark.me.', 'warm-corner.dailymark.me'],
])('normalizes %s', (input, expected) => {
expect(normalizeHostname(input)).toBe(expected);
});
it.each(['127.0.0.1', 'localhost', '*.example.com', 'api.dailymark.me', 'com'])
('rejects %s', value => expect(() => assertReservableHostname(value)).toThrow());- [ ] Step 2: Run test and verify failure
Run: bun test apps/api/src/menu/domains/hostname.spec.ts
Expected: FAIL because hostname functions do not exist.
- [ ] Step 3: Implement normalization and policy
Use domainToASCII, strip one trailing dot, lower-case, enforce DNS total/label length, reject control/wildcard/IP names, and reserve dailymark.me, app, api, admin, docs, analytics, www, mail, status, and menu under the root domain.
- [ ] Step 4: Implement service and controller
reserveDefault(tenantId: string, actorId: string, menuId: string, slug: string): Promise<MenuDomain>;
addCustom(tenantId: string, actorId: string, menuId: string, hostname: string): Promise<MenuDomain>;
list(tenantId: string, menuId: string): Promise<MenuDomain[]>;
disable(tenantId: string, actorId: string, domainId: string): Promise<void>;Owner adds/disables custom domains; admin lists/retries. Unique conflict returns 409 without owner disclosure. Tenant/actor come only from req.user.
- [ ] Step 5: Run gates and commit
bun test apps/api/src/menu/domains/hostname.spec.ts apps/api/test/menu-domains.e2e.spec.ts
bun run --cwd apps/api typecheck
git add apps/api/src/menu/domains apps/api/src/menu/menu.module.ts apps/api/test/menu-domains.e2e.spec.ts
git commit -m "feat(menu): reserve canonical menu domains"Task 2: Exact-Host Identity Policy and QR Schema
Files:
- Create:
apps/api/src/menu/qr/qr-campaign.entity.ts - Create:
apps/api/src/db/migrations/1723400000000-MenuDomainsQr.ts - Modify:
apps/api/src/db/data-source.ts - Modify:
apps/api/src/menu/menu.module.ts - Test:
apps/api/test/menu-domain-identity.e2e.spec.ts
Interfaces:
Consumes: core
menu_domains.Produces: exact active-host lookup and tenant-scoped
qr_campaigns.[ ] Step 1: Write failing RLS tests
it('exact context sees only one active domain', async () => {
const rows = await withHostIdentity(ds, 'a.example', queryDomains);
expect(rows).toEqual([{ normalized_host: 'a.example' }]);
});
it('pending domain is invisible', async () => {
expect(await withHostIdentity(ds, 'pending.example', queryDomains)).toEqual([]);
});- [ ] Step 2: Run test and verify failure
Run: bun test apps/api/test/menu-domain-identity.e2e.spec.ts
Expected: FAIL because policy/table do not exist.
- [ ] Step 3: Implement migration
CREATE POLICY menu_domain_tls_identity ON menu_domains FOR SELECT
USING (
status = 'active'
AND normalized_host = current_setting('rls.menu_domain_host', true)
);Create qr_campaigns with tenant/menu/domain IDs, 128-bit opaque unique code, label, location note, SVG/PNG keys, active flag, UTC timestamps, indexes, ENABLE/FORCE RLS, and symmetric down().
- [ ] Step 4: Register entity and run approved up/down/up
DATABASE_URL=postgres://app:app@localhost:54332/checklist_test bun run --cwd apps/api migration:run
bun test apps/api/test/menu-domain-identity.e2e.spec.ts
DATABASE_URL=postgres://app:app@localhost:54332/checklist_test bun run --cwd apps/api migration:revert
DATABASE_URL=postgres://app:app@localhost:54332/checklist_test bun run --cwd apps/api migration:runExpected: exact context never sees a second or inactive domain.
- [ ] Step 5: Commit
git add apps/api/src/menu/qr apps/api/src/db apps/api/test/menu-domain-identity.e2e.spec.ts
git commit -m "feat(menu): scope TLS identity and QR campaigns"Task 3: DNS Ownership and Routing Verification Worker
Files:
- Create:
apps/api/src/menu/domains/domain-verifier.ts - Create:
apps/api/src/menu/domains/node-dns-probe.ts - Create:
apps/api/src/menu/domains/domain.processor.ts - Create:
apps/api/src/menu/domains/domain.constants.ts - Modify:
apps/api/src/menu/domains/domain.service.ts - Modify:
apps/api/src/menu/menu.module.ts - Test:
apps/api/src/menu/domains/domain-verifier.spec.ts - Test:
apps/api/src/menu/domains/domain.processor.spec.ts
Interfaces:
Consumes:
DomainDnsProbe, root targetmenus.dailymark.me, BullMQ.Produces: idempotent
menu-domain-verifyjob and evidence/status.[ ] Step 1: Write failing verifier test
dns.txt.mockResolvedValue([['dailymark-verification=abc']]);
dns.cname.mockResolvedValue(['menus.dailymark.me']);
expect(await verifyDomain(input, dns)).toMatchObject({
ownership: 'verified', routing: 'verified',
});- [ ] Step 2: Run and verify failure
Run: bun test apps/api/src/menu/domains/domain-verifier.spec.ts apps/api/src/menu/domains/domain.processor.spec.ts
Expected: FAIL because verifier/processor do not exist.
- [ ] Step 3: Implement verification
Ownership TXT is _dailymark-challenge.${host} with exact dailymark-verification=${token}. Routing accepts exact CNAME menus.dailymark.me or configured ingress A/AAAA. Normalize/cap evidence.
- [ ] Step 4: Implement idempotent job
Use job ID menu-domain-verify:${domainId}:${tokenVersion}. Missing results use bounded retry; wrong target becomes actionable error; success becomes verified, then active only after host manifest activation.
- [ ] Step 5: Run and commit
bun test apps/api/src/menu/domains
bun run --cwd apps/api typecheck
git add apps/api/src/menu/domains apps/api/src/menu/menu.module.ts
git commit -m "feat(menu): verify custom-domain DNS"Task 4: Restricted Caddy On-Demand TLS Authorization
Files:
- Create:
apps/api/src/menu/domains/tls-ask.service.ts - Create:
apps/api/src/menu/domains/tls-ask.controller.ts - Create:
apps/api/src/config/namespaces/menu-tls.config.ts - Create:
apps/api/src/config/services/menu-tls-config.service.ts - Modify:
apps/api/src/config/namespaces/index.ts - Modify:
apps/api/src/config/config.module.ts - Modify:
apps/api/src/menu/menu.module.ts - Modify:
Caddyfile - Modify:
docker-compose.yml - Modify:
docker-compose.staging.yml - Modify:
docker-compose.prod.yml - Modify:
.env.dev.example - Modify:
.env.staging.example - Test:
apps/api/src/menu/domains/tls-ask.service.spec.ts - Test:
apps/api/test/menu-tls-ask.e2e.spec.ts
Interfaces:
Consumes: exact-host RLS policy and
CADDY_ASK_TOKEN.Produces: internal
GET /internal/menu-domains/tls/ask?domain=...&token=....[ ] Step 1: Write failing allow/deny/token tests
it.each(['pending.example', 'disabled.example', 'unknown.example'])
('denies %s', async host => expect(await service.allowed(host)).toBe(false));
expect(config.acceptsAskToken('wrong')).toBe(false);- [ ] Step 2: Run and verify failure
Run: bun test apps/api/src/menu/domains/tls-ask.service.spec.ts apps/api/test/menu-tls-ask.e2e.spec.ts
Expected: FAIL because ask service does not exist.
- [ ] Step 3: Implement exact lookup and secret handling
Normalize domain, begin transaction, run select set_config('rls.menu_domain_host',$1,true), select one exact active domain, and close transaction. Return 204 allow/403 deny. Verify ask token constant-time before DB access. Redact token/query from logs.
- [ ] Step 4: Obtain infrastructure approval and configure Caddy
Record authoritative provider, build Caddy with its approved caddy-dns module, and automate wildcard DNS challenge. Configure custom names:
{
on_demand_tls {
ask http://api:3000/internal/menu-domains/tls/ask?token={$CADDY_ASK_TOKEN}
}
}
https:// {
tls { on_demand }
reverse_proxy menu:3010
}Keep explicit apex/app/api/admin/docs/analytics blocks ahead of catch-all and persist caddy_data.
- [ ] Step 5: Run tests/config validation and commit
bun test apps/api/src/menu/domains/tls-ask.service.spec.ts apps/api/test/menu-tls-ask.e2e.spec.ts
docker compose config
git add apps/api/src/menu/domains apps/api/src/config Caddyfile docker-compose.yml docker-compose.staging.yml docker-compose.prod.yml .env.dev.example .env.staging.example apps/api/test/menu-tls-ask.e2e.spec.ts
git commit -m "feat(menu): authorize dynamic menu TLS safely"Task 5: Stable QR Campaign Service
Files:
- Modify:
apps/api/package.json - Modify:
bun.lock - Create:
apps/api/src/menu/qr/qr.dto.ts - Create:
apps/api/src/menu/qr/qr.service.ts - Create:
apps/api/src/menu/qr/qr.controller.ts - Modify:
apps/api/src/menu/menu.module.ts - Test:
apps/api/src/menu/qr/qr.service.spec.ts - Test:
apps/api/test/menu-qr.e2e.spec.ts
Interfaces:
Consumes: active
MenuDomain,ObjectStore,QrCampaign.Produces: campaign CRUD and SVG/PNG artifact metadata.
[ ] Step 1: Obtain dependency approval
bun add --cwd apps/api qrcode
bun add --cwd apps/api --dev @types/qrcode- [ ] Step 2: Write failing stable URL test
const qr = await service.create(TENANT, ACTOR, MENU, { label: 'Table 4' });
expect(qr.url).toMatch(/^https:\/\/warm-corner\.dailymark\.me\/\?c=[A-Za-z0-9_-]+$/);
expect(qr.url).not.toContain(TENANT);- [ ] Step 3: Run and verify failure
Run: bun test apps/api/src/menu/qr/qr.service.spec.ts
Expected: FAIL because QR service does not exist.
- [ ] Step 4: Implement artifacts and endpoints
Generate 128-bit base64url code, stable URL, SVG correction Q, PNG 512/1024, and keys public/qr/${campaignId}/. Admin/owner list/create/disable/rotate; member gets 403. Rotation is explicit and audited.
- [ ] Step 5: Run and commit
bun test apps/api/src/menu/qr apps/api/test/menu-qr.e2e.spec.ts
bun run --cwd apps/api typecheck
git add apps/api/package.json bun.lock apps/api/src/menu/qr apps/api/src/menu/menu.module.ts apps/api/test/menu-qr.e2e.spec.ts
git commit -m "feat(menu): generate stable QR campaigns"Task 6: Domains and QR Management UI
Files:
- Create:
apps/web/ui/app/menus/domains/domainClient.ts - Create:
apps/web/ui/app/menus/domains/DomainsPanel.tsx - Create:
apps/web/ui/app/menus/domains/DomainInstructions.tsx - Create:
apps/web/ui/app/menus/domains/QrCampaignsPanel.tsx - Create:
apps/web/ui/app/menus/domains/domainView.ts - Test:
apps/web/ui/app/menus/domains/domainView.test.ts - Story:
apps/web/ui/app/menus/domains/DomainsPanel.stories.tsx - Modify:
apps/web/ui/app/menus/MenuEditorScreen.tsx - Modify:
apps/web/messages/ru.json - Modify:
apps/web/messages/en.json - Modify:
apps/web/messages/sr.json
Interfaces:
Consumes: domain/QR APIs.
Produces: owner setup, admin retry/health, and campaign downloads.
[ ] Step 1: Write failing state tests
expect(domainAction({ status: 'pending', ownership: 'missing' })).toBe('show_dns_instructions');
expect(domainAction({ status: 'active', ownership: 'verified' })).toBe('show_ready');- [ ] Step 2: Run and verify failure
Run: bun test --cwd apps/web ui/app/menus/domains/domainView.test.ts
Expected: FAIL because domainAction does not exist.
- [ ] Step 3: Implement UI and translated states
Show copyable TXT/CNAME/A instructions, evidence, retry/backoff, TLS state, and exact remediation. QR panel creates label/location, previews stable URL, downloads SVG/PNG, rotates with confirmation, and shows print/scan guidance.
- [ ] Step 4: Run gates and commit
bun test --cwd apps/web ui/app/menus/domains/domainView.test.ts
bun run --cwd apps/web typecheck
bun run --cwd apps/web lint
bun run --cwd apps/web build-storybook
git add apps/web/ui/app/menus apps/web/messages
git commit -m "feat(menu): manage domains and QR campaigns"Task 7: Staging DNS/TLS/QR Acceptance and Runbook
Files:
- Create:
docs-internal/menu-domains-qr-operations.md - Create:
test/menu-domain-qr.e2e.test.ts
Interfaces:
Consumes: complete workstream.
Produces: staging evidence and operator procedure.
[ ] Step 1: Write acceptance test
Exercise known subdomain, verified custom-domain fixture, unknown/disabled denial, campaign stability through publish/rollback, and SVG decode to expected URL.
- [ ] Step 2: Run local acceptance
Run: bun test test/menu-domain-qr.e2e.test.ts
Expected: local host/ask/QR checks PASS; public certificate assertions run only in staging mode.
- [ ] Step 3: Write operations runbook
Document DNS records, wildcard challenge, custom Caddy image, ask-token rotation, verification/TLS diagnosis, safe disable, unknown-host incident, QR reprint rules, and rollback.
- [ ] Step 4: Obtain staging approval and run public drill
Verify certificate SAN/issuer/expiry, redirect, active content, unknown-host denial without issuance, and logs without token leakage.
- [ ] Step 5: Commit
git add docs-internal/menu-domains-qr-operations.md test/menu-domain-qr.e2e.test.ts
git commit -m "test(menu): prove domain and QR lifecycle"Workstream Acceptance Checklist
- [ ] Host normalization is identical across API, TLS authorization, gateway, and tests.
- [ ] Global uniqueness conflict does not disclose tenant ownership.
- [ ] Exact-host RLS identity sees one active row only.
- [ ] Ownership and routing both pass before activation.
- [ ] Ask token is required, constant-time checked, and redacted.
- [ ] Unknown/pending/disabled/reserved hosts cannot authorize issuance.
- [ ] Wildcard certificate uses approved automated DNS challenge.
- [ ] QR excludes tenant/publication IDs and survives publish/rollback.
- [ ] RU/EN/SR owner/admin/member UI states pass.
- [ ] Staging DNS/TLS/QR drill and runbook are complete.