A self-hosted, single-restaurant ordering system: guests order from their table on their own phones, the kitchen and counter work from live boards, and everything runs on one small box behind a Cloudflare tunnel. Blazor Server over PostgreSQL, no external runtime dependencies beyond the database and the tunnel.
All six milestones are complete. Identity and accounts, table administration with rotating join QR codes, paired table displays, the living order and its locking protocol, the kitchen and counter boards, billing and settlement, menu management, the cross-log event explorer — and then the hardening milestone: continuous integration, all fifteen §16.3 end-to-end scenarios running against a real browser, and a backup/restore drill that CI rehearses on every push rather than a runbook nobody has executed.
Two product gaps surfaced during that milestone and were closed rather than noted. Writing the
end-to-end scenarios found that guests had nowhere to self-register despite the requirements having
mandated it since rev 2, so /register exists. Executing the restore procedure for the first time
found that it could not have completed — and that nothing had ever backed up the Data Protection key
ring, so every backup ever taken would have restored the accounts and none of their enrolled
authenticators. Both are in docs/DOCUMENTATION_REVIEW.md as F-37 and F-38.
See Roadmap and docs/BUILD_PROGRESS.md.
The solution (MyRestaurant.slnx) is a small set of projects with a strict dependency direction —
the web layer depends on data-access and the domain; the domain depends on nothing.
src/MyRestaurant.Domain— pure domain logic: the order event model and its fold/validation, the join-token and Argon2 PHC primitives, the sign-in audit and obligations-pipeline decisions, identifiers, clock, and live-update contracts. No I/O.src/MyRestaurant.DataAccess— Dapper + Npgsql, the DbUp migration runner, the embedded SQL schema (Migrations/0001_initial_schema.sql: 22 tables, 5 views, thecitextextension), and the Identity stores (person, roles read side, TOTP secret encrypted at rest, recovery codes, the append-only security-event log). Entity Framework is deliberately not used anywhere.src/MyRestaurant.WebApplication— the composition root, configuration binding and fail-fast validation, OpenTelemetry wiring, the in-process live-update broadcaster, cookie authentication with the auditing sign-in manager, the §3.5 obligations middleware, the account pages including anonymous guest registration (all static SSR, because they write cookies on the response), and the Blazor shell.tests/— pure domain tests, Testcontainers integration tests for migrations, the Identity stores and every reader/mutation over real PostgreSQL, web-layer configuration/wiring/enforcement tests, and the end-to-end scenario matrix with its Playwright harness (see End-to-end scenarios).scripts/— the operational scripts:check_tree.sh(repository hygiene, the first CI gate),check_repository.sh(the governance surface, and an advisory read of the published repository's settings),ci_local.sh(every CI gate, locally),backup.sh/restore.sh/restore_drill.sh(§15 recovery sets and the rehearsal CI runs on every push), andquick_tunnel.sh(a demo origin)..github/workflows/— the CI and release pipelines (see Continuous integration).
- The .NET SDK pinned in
global.json—10.0.100withrollForward: latestMinor, so any 10.0 feature band satisfies it. - A container engine — rootless Podman is the primary target; Docker works too.
- For integration tests, the container engine's API socket must be reachable (see Testing). For the end-to-end scenarios, a Chromium build as well (see End-to-end scenarios).
Host-dev with hot reload (database in a container, web app on the host):
./run.shThis starts PostgreSQL in a container, exports sensible dev defaults, ensures the ASP.NET Core dev
certificate, and runs dotnet watch. The app comes up at https://localhost:8443.
Boot once, verify health, and exit (the end-of-sweep / CI mode):
./run.sh --smokeFull containerized dev stack (adds Caddy for TLS):
./run.sh --containers-only
# equivalently: podman-compose --profile dev up --buildThen trust Caddy's local CA on first use and open https://localhost:8443.
run.sh never opens tunnels or prints public URLs — that is a separate, demo-only step (see
Deployment below and docs/OPERATIONS.md §10).
All configuration is environment-only. Copy .env.example to .env and adjust; the file documents
every variable and its default. The application validates security-relevant settings at startup and
refuses to start on a bad value (non-https origin, Argon2 below the floor, an unresolvable time zone,
a missing connection string, and so on).
dotnet test # everything
dotnet test tests/MyRestaurant.Domain.Tests # pure, fast, no services
dotnet test tests/MyRestaurant.WebApplication.Tests # config binding + identity wiring/enforcement
dotnet test tests/MyRestaurant.DataAccess.Tests # needs a reachable container engineThe domain and web-layer tests need no services. The data-access tests spin up a real PostgreSQL 17 container via Testcontainers; if no container engine is reachable they skip rather than fail.
Rootless Podman (the canonical engine): Testcontainers talks to the engine's API socket, not the
podman CLI, so on a fresh Fedora/Podman machine the integration tests skip with a Docker-flavoured
"endpoint unavailable" message even though run.sh works. Activate the user socket once:
systemctl --user enable --now podman.socketThe test suite then discovers unix://$XDG_RUNTIME_DIR/podman/podman.sock automatically (and
disables Ryuk, which is unreliable rootless — every fixture disposes its own container). Explicit
configuration still wins: DOCKER_HOST or ~/.testcontainers.properties, if set, are respected.
The §16.3 matrix lives in tests/MyRestaurant.EndToEnd.Tests. It is opt-in — a plain
dotnet test skips it — because the first run downloads a Chromium build of roughly 150 MB. Run it
with either of:
MYRESTAURANT_E2E=1 dotnet test tests/MyRestaurant.EndToEnd.Tests
scripts/ci_local.sh --with-e2eEach scenario gets its own stack: a fresh database on a shared PostgreSQL 17 container, a fresh Data Protection key directory, the built web application as a child process on a free loopback port, and a browser context with a CDP WebAuthn virtual authenticator. Nothing is shared between scenarios, so they can run in any order — which matters, because scenario 1 needs a database with no administrator and scenario 13 needs one with an administrator who has both a passkey and TOTP.
All fifteen are implemented:
| # | What it proves |
|---|---|
| 1 | the /setup bootstrap, with a real WebAuthn attestation and a real TOTP code, then /setup is 404 |
| 2 | a display pairs and its QR advances across a rotation boundary |
| 3 | a guest scans, self-registers with a passkey, and joins after the code they scanned has expired |
| 4 | a guest stages two adds and a note, sends, and the kitchen gets exactly one alert with both lines pending |
| 5 | a second guest joins on a fresh token and sees the first guest's order live; the roster updates both ways |
| 6 | the kitchen marks one line away and the guest's own screen re-badges it |
| 7 | removing a fulfilled line rejects the whole batch with a per-operation reason; removing a pending one succeeds |
| 8 | a send left unfulfilled past the threshold produces exactly one reminder, not a stream |
| 9 | the counter adjusts a price with a reason and the guest sees old → new with that reason |
| 10 | the counter closes with a pending-line warning; the table flips to settled read-only and the totals match |
| 11 | a guest hides a closed order, staff and admin views are unchanged, and an administrator finds and unhides it |
| 12 | an admin reset forces password change then TOTP re-enrollment — on the passkey path too |
| 13 | a passkey sign-in of a TOTP-enrolled person is not challenged for a code |
| 14 | the join-token window arithmetic as a guest experiences it |
| 15 | rotating a join secret kills every outstanding QR while the paired display recovers by itself |
Scenario 3 is the one that earns its runtime. §16.3 words it "registers with passkey (slowly — grant outlives token)", and that parenthetical is the entire reason the §4.4 join grant exists. The instance runs at the §13 floor of a ten-second rotation, so the scanned token is provably dead about twenty seconds later; the scenario waits it out, proves the death by re-scanning in a third browser context with no grant cookie to carry it past a refusal, and only then joins on the grant.
Scenarios 4 and 6 are the first to watch a §9 broadcast cross between two circuits — a guest's phone
and the kitchen board, in two browser contexts, reacting to each other's commits. The kitchen board
is opened before anything is sent, because KitchenBoard.razor subscribes in
OnAfterRender(firstRender) and a board opened afterwards would render the queue perfectly well
while having heard nothing.
A scenario that needs more than one principal at once opens more than one browser context — an administrator, the tablet on the table, a guest with a phone. For the display device that is not hygiene but necessity: the §4.2 device credential is ignored on any request the Identity cookie has already authenticated, so a screen paired inside the administrator's browser is the administrator and never renders a join code at all.
The rotation window is per instance rather than global, because the scenarios want opposite things from it: scenario 14 needs one long enough that "the previous window" cannot roll over mid-assertion, while 2 and 15 need one short enough that a boundary is crossed inside a test's patience. §4.3 accepts the current and previous window whatever their width, so nothing an assertion depends on moves with it.
The app is served at http://localhost:{port} while RESTAURANT_PUBLIC_ORIGIN says
https://localhost:{port}. The mismatch is deliberate: §13 refuses to start on a non-https origin,
and Chromium treats localhost as a secure context regardless of scheme, so WebAuthn ceremonies run
and the Secure authentication cookie is accepted. Only the host is ever compared.
On a minimal Linux host Chromium's shared libraries may be missing. Install them once — this needs root, which is why the harness never attempts it:
pwsh tests/MyRestaurant.EndToEnd.Tests/bin/Debug/net10.0/playwright.ps1 install --with-deps chromiumEvery unavailability is a skip with the fix in its message: not opted in, no container engine, no browser, no build output. A missing tool is not a broken product.
Every push and pull request against main runs six gates (.github/workflows/ci.yml):
| Gate | What it proves |
|---|---|
tree |
the checkout is machine-readable at all: no context-dump separator lines, no whitespace-only lines, LF endings with a final newline, every MSBuild and solution file well-formed XML, every YAML file parsing (scripts/check_tree.sh). Asserted over authored text only — generated dumps under docs/llm/ and binary files are skipped, and the counts are reported |
governance |
a security policy exists, README/CONTRIBUTING/SECURITY each point at the others, and no tracked file asserts a repository or package setting (scripts/check_repository.sh). That half is blocking; a second, advisory half reads the GitHub API and reports the settings themselves, because a fork's settings are the fork's business |
shell-scripts |
every tracked *.sh parses under bash -n and passes shellcheck |
build-and-test |
a Release build with warnings escalated to errors, then all ~970 facts — including the data-access integration tests, which run here rather than skipping, because a runner always has a container socket |
boot-smoke |
the production Containerfile builds — which now also means its build context is exactly the allow-list .dockerignore describes, asserted by the build itself — the image boots against a real PostgreSQL until /healthz/ready answers 200, /source names the commit it was built from, and then that instance is backed up and the backup is put through a full restore drill |
end-to-end |
all fifteen §16.3 scenarios in Chromium against the built application, with MYRESTAURANT_E2E=1 |
That third gate is the one worth understanding. /healthz/ready returns 200 only once DbUp has
applied every migration and the composition root has resolved, so it catches the class of failure no
unit test sees: a missing DI registration, a migration that conflicts against a genuinely empty
database, a configuration default the validator rejects. Those do not break a build — they break a
deployment. The same job then proves the instance's data comes back: scripts/backup.sh takes a real
recovery set off it, and scripts/restore_drill.sh restores that set into a scratch container it
creates and destroys, gating the archive, the restore, every relation the migrations declare, DbUp's
journal, all five projection views, and the Data Protection key ring.
The first gate is the cheapest and the newest. It exists because a stray line appended to
Directory.Build.props once failed every MSBuild verb in the repository at once — clean, restore,
build, test and the container build — with a message (Data at the root level is invalid) that
pointed at MSBuild rather than at the file. Twenty other tracked files had the same line and said
nothing, because in YAML, in a Containerfile, in .env and in Markdown it is a comment. It runs in
about two seconds and needs no SDK, so it is worth running by hand after applying anything to this
tree:
bash scripts/check_tree.shRun the same gates locally before pushing:
scripts/ci_local.sh # tree, governance, shell lint, restore, strict build, full suite
scripts/ci_local.sh --with-e2e # ...and the §16.3 scenarios in a real browser
scripts/ci_local.sh --with-smoke # ...and boot once against the dev database
scripts/ci_local.sh --with-all # both of the optional gatesNote that dotnet build on a workstation is deliberately more forgiving than CI:
TreatWarningsAsErrors is switched on only when ContinuousIntegrationBuild=true
(Directory.Build.props), so a fresh clone on a newer SDK still builds through analyzer drift.
scripts/ci_local.sh passes that property, which is the whole reason it exists.
Pushing a v* tag runs the same gates and then publishes ghcr.io/kusl/myrestaurant at
:<version>, :<major>.<minor> and :sha-<commit> (.github/workflows/release.yml). Deploying
from the registry instead of building on the box is docs/OPERATIONS.md §14.
The stack is defined in compose.yaml with two profiles:
- default (
up) —postgres+web, with the web port published on loopback only (headless). --profile dev— adds Caddy terminating TLS athttps://localhost:8443(internal CA).--profile production— adds cloudflared running a named tunnel; TLS terminates at Cloudflare's edge and forwards toweb:8080. SetCLOUDFLARE_TUNNEL_TOKENand changePOSTGRES_PASSWORDandRESTAURANT_PUBLIC_ORIGINfirst.
podman-compose --profile production up -dRESTAURANT_PUBLIC_ORIGIN is the single origin from which the WebAuthn relying-party ID and all QR
and link URLs are derived. In-house guests hairpin through the tunnel, so LAN ordering depends on WAN
health — an accepted tradeoff for this design.
For a throwaway demo over the public internet, one command does it all:
scripts/quick_tunnel.shThe script brings PostgreSQL up, opens a *.trycloudflare.com tunnel, discovers the assigned URL,
sets RESTAURANT_PUBLIC_ORIGIN to it (so QR join links resolve), (re)starts the web app against
that URL, waits for it to answer, prints the URL in a banner, and then holds the tunnel in the
foreground. The URL lives exactly as long as the script runs (Ctrl+C ends the demo) — a quick
tunnel cannot "print a URL and exit", because exiting kills the URL.
Passkeys work on the quick tunnel, including a passkey-only account: the WebAuthn relying-party
ID is derived per request from the host the browser is on (ADR-0005), and https://*.trycloudflare.com
is trusted by default (RESTAURANT_TRUSTED_ORIGIN_PATTERNS). The one caveat, which the script prints
loudly: that hostname is random per run and on the Public Suffix List, so passkeys (and bookmarks) do
not carry across runs — register again on the next run. Use the stable named tunnel for anything that
must persist, and never bootstrap a real instance through a quick tunnel.
A recovery set is two files, not one. scripts/backup.sh writes a pg_dump -Fc archive and a
tar of the Data Protection key ring, sharing one timestamp, to BACKUP_DIRECTORY, then prunes whole
sets to BACKUP_RETENTION_COUNT. Schedule it at BACKUP_SCHEDULE_TIME with a systemd timer or cron.
Without the key ring a restore brings back every account and no enrolled authenticator — the TOTP
secrets are encrypted with it.
scripts/restore.sh <dump> verifies the archive, stops the web app, restores the database, puts the
key ring back, and restarts the app from an EXIT trap so it comes back on every path out of the
script including the failing ones. scripts/restore_drill.sh rehearses the whole thing
non-destructively against a scratch container it creates and destroys — it never touches the live
database. CI runs the drill on every push.
All of this is docs/OPERATIONS.md §6. It reads the way it does because the procedure was executed
for the first time in Slice 16 and four defects fell out of the attempt (F-38); the shape of those
defects is the argument for why a recovery procedure nobody runs is a hypothesis.
Every page's footer carries one quiet line: the product name, the running version, and a link to
/source. That page is anonymous and reports the version, the exact source revision the binary was
built from, and the licence — so the answer to "which build is on that box?" comes from inside the
process rather than from whoever deployed it, and survives a mislabelled image or a tag that has since
moved. The same string is OpenTelemetry's service.version, so a latency change after a deploy is
attributable to a build. A build produced without a revision stamp says "Not recorded" rather than
guessing, which is itself a signal: a production instance saying that did not come from the release
pipeline.
CI proves it: boot-smoke fetches /source with no cookie and fails unless the response names the
commit the image was built from. The stamp travels through a build argument, an MSBuild property, an
assembly attribute, a parse and a component, and every one of those links fails silently — the page
still renders, it just renders "Not recorded".
If you fork this and run it as a network service, AGPL-3.0-only §13 asks you to offer your users the corresponding source of your version. The mechanism is already here and takes one variable:
RESTAURANT_SOURCE_URL=https://git.example.com/you/myrestaurantPublish your modified source there and the footer link already offers it. Stamp your builds with
--build-arg SOURCE_REVISION=$(git rev-parse HEAD) so the offer can name a revision. http is
accepted for this one setting — a Gitea on your LAN discharges the obligation perfectly well. There is
deliberately no setting that removes the offer; if you want it gone, you have the source and the
freedom to remove it, which is the arrangement. Details in docs/OPERATIONS.md §15, and none of it is
legal advice — LICENSE is the text that governs.
Use the private channel, not the issue tracker: the Security tab → Report a vulnerability.
SECURITY.md is the policy — what is in scope, what is out, what happens next, and the honest
timelines of a project one person maintains. There is no bounty, which it says in its second paragraph
rather than leaving you to find out.
Read docs/TECHNICAL_SPECIFICATION.md §17 first. It is the accepted-risks register: the ≤120 s join
token replay window, the ruled absence of a /register rate limit, guests seeing their table-mates'
orders, and half a dozen others are decisions that were argued and written down, each with what bounds
it. An argument that one of them should be re-ruled is welcome. Presenting one as news is an evening
nobody gets back.
CONTRIBUTING.md refuses outside contributions; a vulnerability report is the one exception, and the
reason is that refusing a feature costs the person who wanted it — who has the source and the freedom to
build it — while refusing a report costs an operator's guests, who never chose this software and have no
fork to run.
The code in each milestone slice is written carefully but has not been compiled in its authoring environment (no toolchain or package feed there). On a networked machine:
bash scripts/check_tree.sh— seconds, no SDK; confirms the tree arrived intact.bash scripts/check_repository.sh— seconds; the governance surface, and an advisory read of the repository's own settings.dotnet restore— resolve or adjust any package versions inDirectory.Packages.props.dotnet build— fix any analyzer/compiler findings.dotnet test— domain and web-layer tests need no services; the data-access tests need the container engine socket (see Testing)../run.sh --smoke— confirm migrations apply and/healthz/readyreturns 200.
Or, in one command that mirrors what CI will say about the same tree:
scripts/ci_local.sh --with-all- Warnings are errors in CI, not on your workstation.
TreatWarningsAsErrorsis switched on only underContinuousIntegrationBuild=true(Directory.Build.props), so a fresh clone on a newer SDK builds through analyzer drift while a pull request does not.scripts/ci_local.shasks the strict question locally. - The end-to-end scenarios are opt-in.
MYRESTAURANT_E2E=1is the switch. Without it they skip, and a greendotnet testsays nothing about them — read theend-to-endCI job instead. - Not
InvariantGlobalization. The app resolvesRESTAURANT_TIME_ZONEthroughTimeZoneInfo, so globalization stays on and the runtime image installstzdata. - DbUp logging uses
LogToConsole()rather than a customIUpgradeLog, whose interface shape varies across DbUp versions. If a DbUp version differs from what is pinned,SchemaMigrationRunneris the most likely place a build break appears — adjust the builder calls there. - Npgsql OpenTelemetry. Tracing is enabled with
AddNpgsql(); if the extension's namespace has moved in the pinned Npgsql.OpenTelemetry version, adjust the using directives inProgram.cs. - Forwarded-headers trust.
Program.csclearsKnownIPNetworks/KnownProxiessoX-Forwarded-*from the proxy is honoured. This is safe only because the app is reached exclusively through a trusted proxy (Cloudflare tunnel in production, Caddy in dev) and never exposed directly. - Rootless volume ownership. The data-protection volume is mounted
:Uin compose so Podman chowns it to the container user. On Docker, drop the:Usuffix if it objects. - Account pages are static SSR by design. Sign-in and the forced-change pages write cookies on the response, which a Blazor circuit cannot do; do not convert them to interactive components.
- The first account is created at
/setup, once. On a fresh database only that route is reachable; from the moment an administrator exists it is 404 forever. Do the bootstrap on the production origin, never through a quick tunnel — the passkey binds to the origin it was registered on (docs/OPERATIONS.md§3). /registeris anonymous and not rate-limited. It is the second anonymous surface that writes a row, and the more consequential one, since apersonoutlives the request. What bounds it today is shape rather than policy: two requests behind an antiforgery token and a Data-Protection- protected ticket cookie (so not a scriptable single POST), a 256-character cap on the password so an anonymous caller cannot ask for unbounded Argon2id work, and §3.2's process-wide hashing semaphore./display/pairis the only limited endpoint (§4.2, 5/min/IP), and adding a second policy naively would hijack its rejection message — seedocs/TECHNICAL_SPECIFICATION.md§17.- The source offer has no off switch, and the version is not hidden. A version number on every page is sometimes objected to as telling an attacker which advisories to try. That objection does not survive contact with this project: the source is public, the tags are public, the image digests are public, and concealing the number here would protect nothing while breaking an offer that is supposed to name the version it offers the source of.
- Container images are
linux/amd64only. The release pipeline does not emulate arm64: theContainerfilerunsdotnet publishin its build stage, and doing that under QEMU is slow enough to risk a timeout. An arm64 image wants a cross-compiled publish rather than an emulated one (docs/OPERATIONS.md§14).
- ✔ M1 — skeleton: solution layout,
Containerfile, compose dev profile, DbUp with the initial schema, health endpoints, OpenTelemetry wiring,run.sh. - ✔ M2 — identity & accounts: Dapper Identity stores, Argon2id with the floor guard and
concurrency semaphore, WebAuthn passkeys, TOTP + recovery codes, lockout, the obligations pipeline,
the
/setupfirst-administrator bootstrap, roles → policies → gated areas, security events, account administration, and the person's own profile page. - ✔ M3 — tables & joining: table CRUD, per-table join secrets and rotation, display pairing and
device auth, the
/displaysurface, rotating token generation/validation with metrics, the join grant cookie, sittings and membership. - ✔ M4 — ordering: the living order and its row-level locking protocol, client staging, batch
send with all-or-nothing validation, staff edits, fulfillment and reversal, the projection fold
with equivalence tests, and the kitchen surface with alerts and the reminder loop. Plus the
close-out that made
RESTAURANT_TIME_ZONEactually true on every surface. - ✔ M5 — counter & administration: bills, price adjustment with reason, close & settle, end-of-day, the counter fallback QR, menu management with its event log, the cross-log event explorer, hide/unhide, and post-close corrective events.
- ✔ M6 — hardening: the CI pipeline and publish-on-tag; the Playwright harness and all fifteen
§16.3 scenarios against a real browser; guest self-registration at
/register(F-37); the backup/restore drill, rehearsed by CI on every push rather than written down as a procedure (F-38); and the close-out that stamped the build and shipped the source offer (F-39).
The last thing M6 found was not in the code. Asked what it looked like from outside, the repository
had no security policy, private vulnerability reporting switched off, and a CONTRIBUTING.md that told
every reader the issue tracker was closed — while it was open, and had always been. The AGPL exists to
produce readers, readers are who find security defects, and the only channel that worked was a public
one the documentation denied. SECURITY.md and scripts/check_repository.sh are the answer (F-42).
Every milestone is complete. What follows is maintenance and whatever the restaurant asks for next.