Ekho-Labs / ton-app · pull request #1

M0 Skeleton
security topology, end to end

An empty repository becomes a deployed system: a Vite/React SPA and a Hono Worker on Cloudflare, a hello-world Modal app, and the full two-way auth chain between them — proxy credentials outbound, an HMAC-signed callback inbound. No feature code. The point of the milestone is to prove the security shape before anything of value flows through it.

branchmilestone/m0-skeleton
basemain @ feab3fe
head905c002
commits8
files41 changed
diff+2,396 −0
ci run#31721014918
M0 QA gate PASS
00

What M0 actually built

Three deliverables, all of them thin on purpose. The value is not the code volume — it is that every trust boundary the product will ever need is already crossed, deployed, and asserted in CI.

01 · edge

SPA + Hono Worker

One Cloudflare Worker serves both the static SPA and the API, so the browser sees exactly one origin. run_worker_first is declared for /api/* and /internal/*, so the static-asset handler can never shadow a guarded route.

vite 8react 18hono 4 wrangler 4bun
02 · contract

Canonical §0.3 HMAC

The single signing scheme in the system, implemented twice — once in TypeScript for the Worker, once in Python for Modal — and pinned byte-for-byte by four shared test vectors that both test suites read from the same JSON file.

HMAC-SHA2564 shared vectors 10 TS tests2 py tests
03 · compute + ci

Modal ping app & the gate

A stateless Modal app behind proxy auth that calls back into the Worker with a signed URL, plus the CI that provisions resources, migrates D1, deploys both halves to a per-PR preview, and refuses to go green unless the round trip works.

modal 1.5.4python 3.12 uvPR previews
worker
ton-app-pr-1
preview on workers.dev, torn down on PR close
d1
ton-app-dev
4 tables, migration 0001 applied
kv
ton-app-cache-dev
namespace created, id committed
r2 · blocked
ton-app-dev
account not R2-enabled — binding deferred
modal
ekho-labs / ton-app-dev
1 endpoint, proxy auth required
01

The security topology

One request to /api/health crosses every trust boundary the product will use. Hover any node to isolate its edges. The two red edges are the two authenticated crossings — and they use two different credentials travelling in opposite directions.

call chain authenticated crossing response credential / asset hover a node to isolate · scroll horizontally if clipped
tier 1 Browser
client
src/App.tsx
Mounts, then fetch("/api/health"). Renders the returned status. Holds no credential of any kind.
tier 2 Cloudflare Worker
static
ASSETS
SPA build output, not_found_handling: single-page-application.
route
GET /api/health
Builds the Modal URL from vars, POSTs {base_url: origin} with proxy headers. Maps any non-pong to 502.
guard
/internal/* mw
Verifies the §0.3 signature over method, path, exp and body hash. No session, no other credential accepted.
401
handler
GET /internal/ping
Only reached past the guard. Returns {pong:true}.
The point of the whole milestone

Two crossings, two credential classes, opposite directions. Worker→Modal presents Modal proxy-auth headers; Modal→Worker presents an HMAC signature over the request itself. Neither side ever holds the other platform's credentials: Modal has no Cloudflare token and cannot reach R2 or D1, and the Worker's Modal credential is useless for anything but calling the endpoint. The browser holds neither, and never learns that Modal exists.

02

The canonical §0.3 contract

Every future Modal→Worker call — fetching an upload, posting a parse result, posting a forecast — uses this one scheme. Defining it at M0 and locking it with cross-language vectors means M2 and M3 inherit a proven primitive instead of re-inventing one under deadline.

// worker/security/hmac.ts · modal-app/ton_app/hmac_auth.py sig = HMAC-SHA256(CALLBACK_HMAC_SECRET, method + "\n" + path + "\n" + exp + "\n" + sha256hex(body)) url = {base}{path}?exp={exp}&sig={sig} exp is a unix expiry, strictly in the future and at most 15 minutes ahead. body is the raw request bytes — the hash of the empty string for GET. sig and exp travel as query parameters, so the signature covers the method, the path and the payload but not itself. /internal/* accepts nothing else: no session cookie, no bearer token, no allow-list.
typescript · workerworker/security/hmac.ts
// WebCrypto, runs on the Cloudflare edge
const message =
  `${method.toUpperCase()}\n${path}\n${exp}\n${await sha256hex(body)}`;

return toHex(await crypto.subtle.sign(
  "HMAC", key, encoder.encode(message)));
python · modalmodal-app/ton_app/hmac_auth.py
# stdlib hmac/hashlib, runs in the Modal container
message = f"{method.upper()}\n{path}\n{exp}\n{sha256_hex(body)}"

return hmac.new(
    secret.encode(), message.encode(), hashlib.sha256
).hexdigest()

Locked by four shared vectors

worker/security/hmac-vectors.json is the single source of truth. The Bun suite and the pytest suite both read this same file and assert equality — so the two implementations cannot drift without a red build. Signatures shown truncated; they are derived from a fixed test secret committed alongside the vectors, not from any real key.
methodpathbodysig
GET/internal/ping — empty —6e9c7eea04d3…
POST/internal/datasets/ds_01/parse-result {"status":"done","rows":42}cc7c8f1a9126…
POST/internal/datasets/ds_02/forecast-result {"note":"ünïcode ✓"}f2d7ae62b744…
GET/internal/datasets/ds_01/raw — empty —30a85c44a5c9…

The third vector carries non-ASCII bytes on purpose: it is the one that would catch a TextEncoder versus str.encode() disagreement, which is exactly the class of bug that only ever shows up in production with a customer's file.

What the guard rejects

Six behavioural cases in worker/security/hmac.test.ts, on top of the four vector-equality tests — ten assertions, all green in CI.
Valid, unexpired — accepted, the only accepting path.
Tampered signature — one flipped hex character is rejected.
Expired exp — verified one second past expiry, rejected.
exp > 15 minutes ahead — rejected, so a leaked URL has a bounded life.
Malformed exp — non-numeric fails a ^\d{1,12}$ test before any crypto runs.
Body-hash mismatch — changing rows:42 to 43 invalidates the signature.

Comparison is constant-time over the hex strings, and the expiry window is checked before the HMAC is computed — a malformed exp costs an attacker no signing work on the Worker's ~10 ms CPU budget.

03

The CI pipeline

Six stages on every pull request. Nothing is provisioned, configured or deployed by hand — including the resource ids, which CI verifies against the committed config and refuses to paper over. Evidence quoted below is from run #31721014918 on head 905c002.

1

test — typecheck, both test suites, build

bun run typecheck # tsc over SPA + worker projects bun run test # bun test worker bun run build # vite build uv sync --locked && uv run pytest

10 pass, 0 fail (TypeScript §0.3 suite) · 2 passed in 0.01s (Python vector lock)

2

provision — idempotent, drift-checked

bash scripts/provision.sh dev

D1 ton-app-dev: 0588e4b3… · KV ton-app-cache-dev: 909ea857… · provision dev: OK (ids match wrangler.jsonc)

##[warning] BLOCKER: R2 is not enabled on this Cloudflare account. — degraded, not fatal; see finding O1.

3

migrate — D1 schema

bunx wrangler d1 migrations apply ton-app-dev --remote --env dev

✓ No migrations to apply!0001_initial.sql (users, sessions, datasets, jobs) had already been applied by an earlier run in the series, which is the idempotency working.

4

modal deploy — secret then app

uv run modal secret create ton-app-dev-secrets "CALLBACK_HMAC_SECRET=..." --force uv run modal deploy ton_app/app.py

✓ App deployed in 1.117s — app name resolves from TON_APP_ENV, so the same file deploys ton-app-dev and ton-app-prod.

5

preview worker — generated config, deploy, push secrets

node scripts/gen-pr-config.mjs 1 # -> wrangler.pr.jsonc, worker ton-app-pr-1 bunx wrangler deploy --config wrangler.pr.jsonc wrangler secret put CALLBACK_HMAC_SECRET | MODAL_TOKEN_ID | MODAL_TOKEN_SECRET

Uploaded ton-app-pr-1 (3.34 sec) · 66.42 KiB, gzip 16.46 KiB · bindings env.DB, env.KV · three secrets uploaded. The preview reuses the shared dev resources and drops cron triggers; pr-cleanup.yml deletes the worker when the PR closes.

6

QA gate — the merge condition

bash scripts/qa/m0.sh https://ton-app-pr-1.<subdomain>.workers.dev

M0 QA gate PASS — both assertions below, 11.4 s wall clock. The identical gate runs again on main in deploy-dev.yml after the dev deploy.

04

The gate, and its evidence

docs/plan.md defines M0's exit condition as two curls against a live preview. Both are implemented verbatim in scripts/qa/m0.sh and both are quoted below from the actual CI log. The preview subdomain is redacted here; the deployment itself is real and was reachable during the run.

assertion 1 pass

The full round trip returns pong

GET /api/health must return exactly {"ok":true,"modal":"pong"}. Getting that string requires the Worker to authenticate into Modal, and Modal to authenticate back into the Worker, and the signature to verify. A single broken link in the chain produces a 502 with a different body.

QA gate: M0 — run #31721014918
$ bash scripts/qa/m0.sh https://ton-app-pr-1.<redacted>.workers.dev == M0 gate against https://ton-app-pr-1.<redacted>.workers.dev attempt 1: {"ok":false,"modal":"status 401"} PASS: /api/health == {"ok":true,"modal":"pong"}

Attempt 1 fires 0.6 s after the last wrangler secret put and still sees the old credential state; the retry loop (10 attempts, 6 s apart) absorbs it and attempt 2 passes 11 s later. The distinction matters — see finding N3.

assertion 2 pass

A tampered signature is refused

A second curl hits /internal/ping with a garbage sig and, critically, an exp set to now + 300 s — deliberately inside the 15-minute window. A 401 therefore proves the signature was checked, not merely that an expiry was stale.

tampered signature probe
$ exp=$(( $(date +%s) + 300 )) $ curl -o /dev/null -w '%{http_code}' \ "$BASE/internal/ping?exp=$exp&sig=deadbeef...deadbeef" 401 PASS: tampered /internal/ping -> 401 M0 QA gate PASS

The route is reachable and returns 401 rather than 404 — the guard ran. With a correct signature the same URL returns {pong:true}, which is what assertion 1 exercises from inside Modal.

Why this gate is worth its runtime

It is a real deployment, not a mock: a per-PR Worker on Cloudflare's edge, a real Modal container, real secrets pushed through the real path. That is precisely why it caught the proxy-token defect (finding R1) that no unit test could have — both HMAC suites were green while the system was completely unable to talk to Modal.

05

What landed, by area

41 files, +2,396 lines, no deletions — the repository held only README.md and docs/plan.md before this branch. Roughly half the line count is two lockfiles.

worker8 files · 278
  • security/hmac.ts74
  • security/hmac.test.ts63
  • routes/health.ts37
  • security/hmac-vectors.json34
  • routes/internal.ts19
  • tsconfig.json19
  • env.ts17
  • index.ts15
spa6 files · 95
  • src/styles.css22
  • src/App.tsx20
  • tsconfig.json18
  • src/main.tsx12
  • index.html12
  • vite.config.ts11
modal6 files · 1,056
  • uv.lock916
  • ton_app/app.py47
  • ton_app/hmac_auth.py38
  • tests/test_hmac.py32
  • pyproject.toml23
  • ton_app/__init__.py0
ci workflows4 files · 210
  • ci.yml104
  • deploy-dev.yml69
  • provision.yml21
  • pr-cleanup.yml16
scripts4 files · 189
  • provision.sh108
  • qa/m0.sh35
  • gen-pr-config.mjs32
  • bootstrap-pubkey.pem14
config & schema5 files · 488
  • bun.lock346
  • wrangler.jsonc60
  • migrations/0001_initial.sql46
  • package.json26
  • .gitignore10
repo noise8 files · 80
  • .omo/run-continuation/ses_…json ×880

Agent harness state committed with the branch — not product code, and .omo/ is absent from .gitignore. See finding N1.

06

Findings & discoveries

What the six CI runs on this branch taught us. One infrastructure defect was found and fixed inside the milestone; one account-level blocker remains open and lands on M2. Click any row to expand.

resolved open notes
07

Where this leaves the plan

M0
skeleton & topology
M1
auth + tenancy
M2
ingestion · needs R2
M3
forecast engine
M4
screens + narratives
M5
export, prod, DNS

M0 is done on its own terms

The milestone's stated purpose was to prove the security topology before any feature code exists, and the gate proves it against a real deployment rather than a mock. The signing contract that every later milestone depends on is defined once, implemented twice, and locked by shared vectors — so M2's parse callback and M3's forecast callback inherit a primitive that is already tested rather than one written in a hurry against a deadline.

deliberately not here
  • No authentication, sessions or users — M1.
  • No upload, parsing or mapping UI — M2.
  • No forecasting; statsforecast is not even a dependency yet — M3.
  • D1 and KV are bound and migrated but no route reads them.
  • Prod resource ids are still placeholders; prod is provisioned at M5.
before M2 can start
  • Enable R2 on the Cloudflare account — one human click, then re-run provision.
  • Uncomment both r2_buckets bindings and drop the R2? optionality.
  • Add .omo/ to .gitignore and drop the eight committed state files.