The control-plane console API (CF-11). A single Node 22 Lambda behind an HTTP API Gateway (v2) that manages redirect/rewrite rules in DynamoDB. It is a client of the rules table — it only ever writes rules the Lambda@Edge reads; the two never talk directly.
Spec-first: openapi.yaml is the contract, and every rule
request/response body $refs the schemas in ../../shared — rule
shapes are never redefined here.
| Method | Path | Status |
|---|---|---|
GET |
/health |
✅ implemented |
GET / POST |
/targets |
✅ implemented (CF-12) |
GET/PUT/DELETE |
/targets/{id} |
✅ implemented (CF-12) |
GET / POST |
/targets/{targetId}/hosts/{host}/rules |
✅ implemented (CF-13) |
GET/PUT/DELETE |
/targets/{targetId}/hosts/{host}/rules/{sk} |
✅ implemented (CF-13) |
PATCH |
/targets/{targetId}/hosts/{host}/rules/{sk} |
✅ disabled toggle (CF-13) |
Rule routes are scoped to a target (a DynamoDB table from the targets registry, CF-12) and a host (the partition key), and write to that target's table.
The server owns both keys. A request body carries the rule's fields plus a
priority integer; pk is the host from the path and sk is
TYPE#priority, zero-padded to five digits (REDIRECT#00100). Responses are the
stored item — the exact shape the Lambda@Edge reads. Because the priority is part
of the key, a PUT that changes it moves the rule: one transaction writes the
new key and removes the old, so the rule is never live at two priorities.
PATCH is the exception to full-replace semantics: it takes { "disabled": … }
and nothing else, so turning a rule off never depends on the client holding a
complete copy of it. A disabled rule keeps its place in the table and its
priority; the Lambda@Edge skips it. Like every rule change, it reaches the edge
within the edge cache TTL (~1 min), not instantly.
- Router (
src/router.ts) — a hand-rolled method+path matcher with:paramextraction. No web framework; one plain Lambda. - One request path —
src/handler.ts(Lambda) andsrc/local.ts(dev server) both build the sameApiRequestand run the same router. - Errors (
src/lib/errors.ts) — every failure is anApiErrorserialized to a standard envelope:{ "error": { "code", "message", "details"? } }.codeis a stable string the SPA switches on. - Validation (
src/lib/validate.ts) — Ajv compiled against the shared schemas; failures become400 VALIDATION_ERRORwith per-fielddetails.
src/lib/verify-table.ts rejects a new target when DescribeTable says
ResourceNotFoundException, and lets every other failure through with a
warning. That asymmetry is deliberate, and worth knowing before you tighten it.
A mistyped table name would otherwise be a valid registration. The duplicate
check compares tableName exactly — and has to, since DynamoDB table names are
case-sensitive — so Edgeroute-rules is a different table from
edgeroute-rules, not a duplicate. Both entries sit in the registry under the
same display name, and the broken one only surfaces on the first rules request,
as a 502.
But "cannot tell" is not "does not exist". A target that is unreachable right
now is expected: registering is a runtime action while IAM is granted at apply
time, so target_table_arns is routinely still empty when the target goes in.
Rejecting on a failed assume-role, a denied DescribeTable or a throttle would
break that order; those keep surfacing later as 502 TARGET_UNREACHABLE, which
explains itself.
Which makes the IAM grant decide how much of the check works, because IAM
answers a denied DescribeTable before DynamoDB can say whether the table is
there. The module grants it account-wide (table/*, the DescribeTargetTables
statement) rather than over target_table_arns: a grant scoped to the tables
that should exist denies precisely the mistyped names, which arrive as
AccessDenied and are allowed through. For a target with its own roleArn the
same applies to that role's policy, which this module does not write — if it
names its table exactly, a typo reads as "cannot tell" and the check is inert
for that target. The could not verify table warning is what makes that visible.
npm run dev -w console/api # local HTTP server on :3000 (PORT to override)
curl localhost:3000/health # {"status":"ok"}npm test -w console/api # vitest (router, handler, validation)
npm run typecheck -w console/api # tsc --noEmit
npm run openapi:lint -w console/api # redocly lint openapi.yaml
npm run build -w console/api # esbuild -> dist/index.mjsAll four run in CI.
Terraform lives in infra/ — HTTP API Gateway + Lambda + IAM + logs.
See its README, and cognito.tf beside it for auth (CF-23):
a Cognito user pool, and a JWT authorizer that refuses a request at the gateway
before it reaches the Lambda. Four routes stay public — /health, and the three
/auth routes that issue the token in the first place.