Skip to content

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 ask returns 2xx only for an exact active domain 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-verify worker.
  • 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.
ts
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/domains CRUD and one canonical hostname algorithm for DNS/TLS/gateway/QR.

  • [ ] Step 1: Write failing hostname table tests

ts
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
ts
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
bash
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

ts
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
sql
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
bash
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:run

Expected: exact context never sees a second or inactive domain.

  • [ ] Step 5: Commit
bash
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 target menus.dailymark.me, BullMQ.

  • Produces: idempotent menu-domain-verify job and evidence/status.

  • [ ] Step 1: Write failing verifier test

ts
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
bash
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

ts
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:

caddy
{
  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
bash
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

bash
bun add --cwd apps/api qrcode
bun add --cwd apps/api --dev @types/qrcode
  • [ ] Step 2: Write failing stable URL test
ts
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
bash
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

ts
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
bash
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
bash
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.