@nurix/etna 0.18.14 → 0.18.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -16
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +71 -57
- package/data/harnesses/code/agents/{spec-mode.md → lld-mode.md} +12 -12
- package/data/harnesses/code/agents/maintenance.md +2 -31
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/hooks.json +0 -23
- package/data/harnesses/code/rules/testing.md +25 -0
- package/data/harnesses/code/skills/boundary/SKILL.md +34 -14
- package/data/harnesses/code/skills/boundary/references/index.md +16 -0
- package/data/harnesses/code/skills/boundary/references/mechanisms.md +99 -0
- package/data/harnesses/code/skills/boundary/references/packs/async-messaging.md +81 -0
- package/data/harnesses/code/skills/boundary/references/packs/auth.md +93 -0
- package/data/harnesses/code/skills/boundary/references/packs/build-config-runtime.md +93 -0
- package/data/harnesses/code/skills/boundary/references/packs/cloudflare.md +93 -0
- package/data/harnesses/code/skills/boundary/references/packs/frontend.md +93 -0
- package/data/harnesses/code/skills/boundary/references/packs/generic-tail.md +55 -0
- package/data/harnesses/code/skills/boundary/references/packs/persistence.md +93 -0
- package/data/harnesses/code/skills/boundary/references/packs/wire-api.md +87 -0
- package/data/harnesses/code/skills/brainstorm/README.md +28 -10
- package/data/harnesses/code/skills/brainstorm/SKILL.md +27 -12
- package/data/harnesses/code/skills/brainstorm/exit-artifacts.md +2 -2
- package/data/harnesses/code/skills/brainstorm/templates/evidence.md +24 -0
- package/data/harnesses/code/skills/brainstorm/templates/rejected.md +5 -1
- package/data/harnesses/code/skills/documentation/SKILL.md +2 -2
- package/data/harnesses/code/skills/documentation/references/templates.md +10 -77
- package/data/harnesses/etna/CLAUDE.md +11 -12
- package/data/harnesses/etna/agents/{spec-mode.md → lld-mode.md} +12 -12
- package/data/harnesses/etna/agents/maintenance.md +2 -31
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/hooks/hooks.json +0 -23
- package/data/harnesses/etna/rules/testing.md +25 -0
- package/data/harnesses/etna/skills/boundary/SKILL.md +34 -14
- package/data/harnesses/etna/skills/boundary/references/index.md +16 -0
- package/data/harnesses/etna/skills/boundary/references/mechanisms.md +99 -0
- package/data/harnesses/etna/skills/boundary/references/packs/async-messaging.md +81 -0
- package/data/harnesses/etna/skills/boundary/references/packs/auth.md +93 -0
- package/data/harnesses/etna/skills/boundary/references/packs/build-config-runtime.md +93 -0
- package/data/harnesses/etna/skills/boundary/references/packs/cloudflare.md +93 -0
- package/data/harnesses/etna/skills/boundary/references/packs/frontend.md +93 -0
- package/data/harnesses/etna/skills/boundary/references/packs/generic-tail.md +55 -0
- package/data/harnesses/etna/skills/boundary/references/packs/persistence.md +93 -0
- package/data/harnesses/etna/skills/boundary/references/packs/wire-api.md +87 -0
- package/data/harnesses/etna/skills/brainstorm/README.md +28 -10
- package/data/harnesses/etna/skills/brainstorm/SKILL.md +27 -12
- package/data/harnesses/etna/skills/brainstorm/exit-artifacts.md +2 -2
- package/data/harnesses/etna/skills/brainstorm/templates/evidence.md +24 -0
- package/data/harnesses/etna/skills/brainstorm/templates/rejected.md +5 -1
- package/data/harnesses/etna/skills/documentation/SKILL.md +2 -2
- package/data/harnesses/etna/skills/documentation/references/templates.md +10 -77
- package/data/harnesses/mobile/CLAUDE.md +78 -55
- package/data/harnesses/mobile/agents/{spec-mode.md → lld-mode.md} +12 -12
- package/data/harnesses/mobile/agents/maintenance.md +2 -31
- package/data/harnesses/mobile/harness.json +2 -2
- package/data/harnesses/mobile/hooks/hooks.json +0 -23
- package/data/harnesses/mobile/rules/testing.md +25 -0
- package/data/harnesses/mobile/skills/boundary/SKILL.md +34 -14
- package/data/harnesses/mobile/skills/boundary/references/index.md +16 -0
- package/data/harnesses/mobile/skills/boundary/references/mechanisms.md +99 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/async-messaging.md +81 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/auth.md +93 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/build-config-runtime.md +93 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/cloudflare.md +93 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/frontend.md +93 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/generic-tail.md +55 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/persistence.md +93 -0
- package/data/harnesses/mobile/skills/boundary/references/packs/wire-api.md +87 -0
- package/data/harnesses/mobile/skills/brainstorm/README.md +28 -10
- package/data/harnesses/mobile/skills/brainstorm/SKILL.md +27 -12
- package/data/harnesses/mobile/skills/brainstorm/exit-artifacts.md +2 -2
- package/data/harnesses/mobile/skills/brainstorm/templates/evidence.md +24 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/rejected.md +5 -1
- package/data/harnesses/mobile/skills/documentation/SKILL.md +2 -2
- package/data/harnesses/mobile/skills/documentation/references/templates.md +10 -77
- package/package.json +1 -1
- package/data/harnesses/code/hooks/lib/redact.mjs +0 -35
- package/data/harnesses/code/hooks/prompt-recall.mjs +0 -155
- package/data/harnesses/code/hooks/session-reflect.mjs +0 -285
- package/data/harnesses/code/skills/boundary/references/boundary-taxonomy.md +0 -796
- package/data/harnesses/code/skills/distiller/SKILL.md +0 -90
- package/data/harnesses/code/skills/distiller/references/docs-mode.md +0 -299
- package/data/harnesses/code/skills/distiller/references/method.md +0 -94
- package/data/harnesses/etna/hooks/lib/redact.mjs +0 -35
- package/data/harnesses/etna/hooks/prompt-recall.mjs +0 -155
- package/data/harnesses/etna/hooks/session-reflect.mjs +0 -285
- package/data/harnesses/etna/skills/boundary/references/boundary-taxonomy.md +0 -796
- package/data/harnesses/etna/skills/distiller/SKILL.md +0 -90
- package/data/harnesses/etna/skills/distiller/references/docs-mode.md +0 -299
- package/data/harnesses/etna/skills/distiller/references/method.md +0 -94
- package/data/harnesses/mobile/hooks/lib/redact.mjs +0 -35
- package/data/harnesses/mobile/hooks/prompt-recall.mjs +0 -155
- package/data/harnesses/mobile/hooks/session-reflect.mjs +0 -285
- package/data/harnesses/mobile/skills/boundary/references/boundary-taxonomy.md +0 -796
- package/data/harnesses/mobile/skills/distiller/SKILL.md +0 -90
- package/data/harnesses/mobile/skills/distiller/references/docs-mode.md +0 -299
- package/data/harnesses/mobile/skills/distiller/references/method.md +0 -94
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Pack routing index
|
|
2
|
+
|
|
3
|
+
Load when: every boundary check, right after `mechanisms.md`. Match the touch list against the signals below and load **only** the packs whose signals appear in the touched files or the plan's Proposed Changes. Several packs matching is normal (a full-stack feature typically routes 2–4). A technology matching nothing here is still in scope — diagnose it from its mechanism, and check `packs/generic-tail.md` for a thin entry.
|
|
4
|
+
|
|
5
|
+
| Pack | Load when the touch list shows |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `packs/persistence.md` | `schema.ts`, `init.sql`, `sqliteTable(`, `drizzle-kit`, `pgTable`, migrations, `*.seed.sql`, seed generators, JSON columns, `pgEnum`, pgvector, Redis `get`/`set` caching |
|
|
8
|
+
| `packs/wire-api.md` | Express routes/middleware (`app.use(`, `router.`), `res.json({ status`, zod schemas on requests, axios clients/interceptors, `cors(`, inbound webhooks, `rateLimit(` |
|
|
9
|
+
| `packs/frontend.md` | `createSlice`/`configureStore`, RTK Query (`tagTypes`), react-router tables/`useParams`, Tailwind CSS (`@theme`, `personality.css`, `globals.css`), `vite.config.*` (alias, env), `@nurix/components`, localforage/redux-persist, date formatting |
|
|
10
|
+
| `packs/auth.md` | `jwtVerify(`/jose, `betterAuth(`/`auth.api.`, session cookies, CSRF, OAuth callbacks/`redirect_uri`, `tenantId`/`organizationId` scoping, password hashing, auth middleware mounts |
|
|
11
|
+
| `packs/cloudflare.md` | `wrangler.jsonc`/`wrangler.toml`, `env.X` bindings, `extends DurableObject`/`[[migrations]]`, KV/R2/D1/Queues, `scheduled(`/crons, `WorkerEntrypoint`, `compatibility_date`, static assets config |
|
|
12
|
+
| `packs/async-messaging.md` | BullMQ (`new Queue(`/`new Worker(`), Redis pub/sub or Streams, WebSockets/socket.io, SSE (`text/event-stream`), channel-name templates, outbound webhook senders |
|
|
13
|
+
| `packs/build-config-runtime.md` | `package.json` (deps/`exports`/scripts/`packageManager`), lockfiles, `.env*`, tsconfig `paths`, Dockerfiles/compose, `.github/workflows/`, pino config, `trust proxy`, ports/healthchecks |
|
|
14
|
+
| `packs/generic-tail.md` | GraphQL, gRPC/protobuf, tRPC, OpenAPI codegen, Kafka/schema registry/CDC, workflow engines, event sourcing, Postgres RLS/pooling/collation, SSR/hydration, module federation, i18n, analytics plans, hosted feature flags, K8s probes/shutdown/limits, service mesh, IaC outputs, SAML, Lambda, IPC |
|
|
15
|
+
|
|
16
|
+
Two files never route — they always load: `mechanisms.md` (the seven failure mechanisms + domain contracts) and this index.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Boundary Mechanisms
|
|
2
|
+
|
|
3
|
+
Load when: every boundary check — this is the core reference. The technology packs under `packs/` instantiate these mechanisms with exact detect signals and gotchas; `index.md` routes to the packs the touched code exhibits.
|
|
4
|
+
|
|
5
|
+
A boundary is a contract between two pieces of code (or code and infra) that share an assumption with **no static link** between them. The litmus is **"would it ship green?"** — change one side, everything compiles, lints, and tests pass, and the other side breaks at runtime. An edge the compiler or an existing test already guards is not a boundary.
|
|
6
|
+
|
|
7
|
+
Every boundary instantiates one of seven mechanisms. The mechanism tells you what to grep for, what the universal fix is, and how bad a break is — including for technologies no pack covers. When touched code exhibits a contract that matches no pack entry, diagnose it from its mechanism.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1 · rendezvous-string
|
|
12
|
+
|
|
13
|
+
Two sides meet on a literal identifier: a column name in raw SQL, an event/job/topic name, a route path, an env key, a cache key, a channel template, a DI token, a flag key, a metric name, a port number, a binding name. The compiler sees two unrelated literals.
|
|
14
|
+
|
|
15
|
+
- **Ask:** where else does this exact string (or its template) appear — including config, IaC, CI, dashboards, and other repos?
|
|
16
|
+
- **Failure:** the sides silently stop meeting — publishes into the void, reads `undefined`, routes 404, handler never fires, alert goes quiet.
|
|
17
|
+
- **Trace:** grep the literal and its prefix/template variants repo-wide; the other side is often not code (a deploy manifest, a saved dashboard query, a provider allowlist).
|
|
18
|
+
- **Universal fix:** centralize the identifier in one shared constant or module. On rename, serve both old and new until the old side drains — in-flight jobs, cached entries, sent emails, deployed clients.
|
|
19
|
+
- **Severity:** HIGH by default; CRITICAL when the string scopes trust (a tenant segment in a channel name, a redirect-URI allowlist entry).
|
|
20
|
+
|
|
21
|
+
## 2 · serialized-shape
|
|
22
|
+
|
|
23
|
+
A payload crosses a gap in space or time — a process boundary, a deploy window, persistence — and the reader is not the writer. Old data and mixed-version peers must still parse. The defining sub-case is the **mixed-version window**: rolling deploys, in-flight queue items, long-lived clients, persisted events, browser storage — the old shape exists somewhere and outlives the change.
|
|
24
|
+
|
|
25
|
+
- **Ask:** who reads this shape that doesn't build from this commit — old rows, queued messages, deployed clients, replayed histories?
|
|
26
|
+
- **Failure:** the reader crashes or misreads with no error at the writer; persisted old shapes fail **forever**, not just during the deploy.
|
|
27
|
+
- **Trace:** find every deserializer of the shape (parsers, `JSON.parse` + property reads, ORM rehydration, client unwrappers) and every store where old instances persist.
|
|
28
|
+
- **Universal fix:** additive-only changes (add optional fields, never rename/retype/remove); carry an explicit version and read all live versions; readers tolerate unknown fields and ignore unknown discriminators.
|
|
29
|
+
- **Severity:** CRITICAL when the shape is persisted or replayed (the break is historical, not just live); HIGH for live-traffic shapes.
|
|
30
|
+
|
|
31
|
+
## 3 · generated-artifact
|
|
32
|
+
|
|
33
|
+
A derived artifact must stay in sync with its source: ORM client from schema, types from codegen, lockfile from package.json, bundler aliases from tsconfig, seed SQL from a CSV. The artifact is trusted as if it were the source — when they drift, the artifact **lies** to everything that consumes it.
|
|
34
|
+
|
|
35
|
+
- **Ask:** what generates this, what does it generate, and would CI notice if they drifted?
|
|
36
|
+
- **Failure:** code compiles against stale types and mismatches at runtime; installs resolve a tree nobody tested; hand-edits are clobbered on the next generation.
|
|
37
|
+
- **Trace:** find the generator invocation (scripts, `prepack`, CI steps) and every consumer of the output; check whether generation is enforced or manual.
|
|
38
|
+
- **Universal fix:** regenerate in the same commit as the source change; CI-gate drift (`git diff --exit-code` after generate); never hand-edit generated output.
|
|
39
|
+
- **Severity:** HIGH by default; CRITICAL when the artifact governs installs or schema (lockfile, migrations).
|
|
40
|
+
|
|
41
|
+
## 4 · dual-authority
|
|
42
|
+
|
|
43
|
+
Two independent writers own the same state: two migration tools targeting one database, a framework that owns its own tables, an app route table and an ingress rule, hand-written DDL and an ORM mirror. Each authority is internally consistent; the state they share is not.
|
|
44
|
+
|
|
45
|
+
- **Ask:** who else writes this — and does either writer know the other exists?
|
|
46
|
+
- **Failure:** one authority clobbers or drifts from the other; the loser's consumers break with no signal on the winner's side (a schema push drops the auth library's tables; login breaks).
|
|
47
|
+
- **Trace:** list every tool/config with write authority over the state (migration runners, CLIs, IaC, admin dashboards) and what each believes the state is.
|
|
48
|
+
- **Universal fix:** designate one canonical authority and make the others read-only mirrors reconciled from it — or byte-align them and CI-check the parity.
|
|
49
|
+
- **Severity:** CRITICAL when the shared state is persisted data or auth; HIGH otherwise.
|
|
50
|
+
|
|
51
|
+
## 5 · config-elsewhere
|
|
52
|
+
|
|
53
|
+
A value is declared in one place and consumed in another — env vars, platform bindings, secret paths, ports, build-time defines. Reader and provider never reference each other statically, and the provider is often per-environment, so the break can exist in exactly one environment.
|
|
54
|
+
|
|
55
|
+
- **Ask:** for each value this code reads, where is it declared — in **every** environment, not just the one you can see?
|
|
56
|
+
- **Failure:** `undefined` at request time, boot failure in one env only, a stale build-time literal where a runtime value was expected, a secret baked into a public artifact.
|
|
57
|
+
- **Trace:** map each read (`process.env.X`, `env.BINDING`, `import.meta.env.Y`) to its declaration in every env file, deploy manifest, CI secret store, and dashboard.
|
|
58
|
+
- **Universal fix:** validate the full key set at boot and fail fast; keep a tracked example/contract file (`.env.example`); change reader and every provider in one commit.
|
|
59
|
+
- **Severity:** HIGH by default; CRITICAL when the flow direction is wrong (a secret reaching a client bundle) — that is also a trust-invariant break.
|
|
60
|
+
|
|
61
|
+
## 6 · trust-invariant
|
|
62
|
+
|
|
63
|
+
An auth or isolation guarantee that **fails open**: tenancy scoping, RLS policies, cookie flags, CSRF defenses, redirect-URI allowlists, JWT audience checks, public-env prefixes. Unlike the other mechanisms, breaking it doesn't stop the system — everything keeps working, plus an attacker path or a cross-tenant leak.
|
|
64
|
+
|
|
65
|
+
- **Ask:** if this check were deleted, would anything visibly fail — or would the system happily serve data it shouldn't?
|
|
66
|
+
- **Failure:** silent grant or disclosure. There is no error to observe; tests that only exercise the happy path stay green by construction.
|
|
67
|
+
- **Trace:** find every enforcement point of the invariant and every path that bypasses it (new endpoints, new tables, raw queries, `SECURITY DEFINER`-style escape hatches, spoofable inputs like `req.body.tenantId`).
|
|
68
|
+
- **Universal fix:** enforce centrally (middleware, policy, schema default) so a new code path inherits the invariant instead of having to remember it; default-deny on any missing input.
|
|
69
|
+
- **Severity:** CRITICAL, categorically. Loud auth failures (everyone locked out) cap at HIGH; it's the quiet successes that are CRITICAL.
|
|
70
|
+
|
|
71
|
+
## 7 · lifecycle-protocol
|
|
72
|
+
|
|
73
|
+
An ordering or sequencing agreement with a platform or across deploys: expand-then-contract migrations, deploy order between producer and consumer, graceful-shutdown draining, replay determinism, retry/idempotency budgets, timezone/DST assumptions, append-only version tags.
|
|
74
|
+
|
|
75
|
+
- **Ask:** does correctness depend on *when* or *in what order* this runs — and who else assumes that order?
|
|
76
|
+
- **Failure:** correct code applied in the wrong order: a migration that strands a rolling deploy, a retry that double-charges, a rename that orphans live stateful objects, a cron that fires on the wrong day twice a year.
|
|
77
|
+
- **Trace:** reconstruct the sequence the change requires (what must exist before what) and compare against how deploys actually roll out — including crash/retry paths.
|
|
78
|
+
- **Universal fix:** make each step independently safe (idempotent handlers, additive-first migrations, append-only version markers); many boundary violations dissolve by **reordering the plan**, not changing what ships.
|
|
79
|
+
- **Severity:** CRITICAL when a wrong order corrupts persisted state or duplicates money-adjacent side effects; HIGH otherwise.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Domain contracts — stack-independent, always checked
|
|
84
|
+
|
|
85
|
+
Two contracts come from the product's information architecture, not from any technology, and are checked on every run:
|
|
86
|
+
|
|
87
|
+
- **entity-model — CRITICAL.** The entity vocabulary and topology (what the entities are, how they relate) that every module, migration, wire type, and doc agrees on. Re-relating or renaming an entity is a change to *every* mechanism at once. Sourced from `docs/architecture/` (domain model, ADRs), a contracts package, and the schema — never from a single module's view of it. Safe change: map every consumer before re-relating; coordinate schema + wire + client in one planned sequence.
|
|
88
|
+
- **tenancy — CRITICAL · trust-invariant.** The invariant that every request carries a non-spoofable tenant identity and all data access is scoped by it, at every layer. Its enforcement points are technology-specific (a JWT claim, a channel-name segment, an RLS policy, a scoped query helper) but the invariant is one contract — a single unscoped path anywhere breaks it.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Severity recap
|
|
93
|
+
|
|
94
|
+
- **CRITICAL** — can corrupt persisted data, or open an auth/money path. Trust-invariants and persisted/replayed shapes live here.
|
|
95
|
+
- **HIGH** — silent cross-module breakage that ships green.
|
|
96
|
+
- **MEDIUM** — drift-prone coupling (string-matched, duplicated constants) not yet broken.
|
|
97
|
+
- **LOW** — smell. Loud, immediately-visible runtime breakage caps at HIGH; statically-guarded edges are out of scope entirely.
|
|
98
|
+
|
|
99
|
+
Severity is intrinsic to the finding, not curved: three genuine CRITICALs in a small repo is correct signal, not inflation.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Pack: async jobs, messaging, realtime
|
|
2
|
+
|
|
3
|
+
Load when: the touch list exhibits BullMQ (`new Queue(`, `new Worker(`), Redis pub/sub or Streams (`publish(`, `xReadGroup(`), WebSockets / socket.io (`ws.send(`, `socket.emit(`), SSE (`text/event-stream`), realtime channel templates, or outbound webhook senders.
|
|
4
|
+
|
|
5
|
+
## bullmq-job-payload-shape — HIGH · serialized-shape
|
|
6
|
+
**Contract:** BullMQ producer's `queue.add(name, data)` payload and the worker's `job.data` reads agree on a JSON shape with zero schema enforcement — and Redis holds in-flight jobs serialized in the OLD shape across any rolling deploy.
|
|
7
|
+
**Detect:** `queue.add(`, `job.data`, `new Queue(`, `new Worker(`, `**/queues/**`, `**/workers/**`, `flow.add(`
|
|
8
|
+
**Ships green, breaks:** Deploy new worker first → it dequeues jobs already sitting in `wait`/`delayed`/`prioritized` (plus every retry of an old `failed` job, which replays the ORIGINAL payload, possibly days later) with the old shape → `undefined` field reads, or worse: no crash, wrong values written. Deploy producer first → old workers process new-shape jobs. TypeScript can't help; the queue boundary erases types.
|
|
9
|
+
**Safe change:** Add fields optional-first, worker tolerates both shapes; deploy worker before producer; only remove old-shape handling after the queue AND the `failed` set drain (retries replay old payloads); consider a `v` field in `data` and branch on it.
|
|
10
|
+
|
|
11
|
+
## bullmq-job-name-routing — HIGH · rendezvous-string
|
|
12
|
+
**Contract:** The first argument of `queue.add('email-welcome', …)` and the worker's `switch (job.name)` meet on a bare string literal; BullMQ workers receive EVERY job on the queue regardless of name — there is no named-processor registration (unlike Bull v3's `process('name', fn)`).
|
|
13
|
+
**Detect:** `queue.add('`, `job.name`, `switch (job.name)`, `case '`
|
|
14
|
+
**Ships green, breaks:** Rename either side and the compiler sees two unrelated literals. Worst variant: a `switch` without a throwing `default` — the processor falls through, returns `undefined`, and BullMQ marks the job **completed successfully**. No error, no failed job, no retry; the work silently never happens.
|
|
15
|
+
**Safe change:** Centralize job names in one shared const/enum imported by both sides; make the worker's `default` branch throw `UnrecoverableError` so unknown names surface in `failed`; on rename, worker accepts old+new names during the deploy window.
|
|
16
|
+
|
|
17
|
+
## bullmq-repeatable-scheduler-identity — HIGH · rendezvous-string
|
|
18
|
+
**Contract:** A recurring job's identity is a string: legacy repeatables (`{ repeat: {…} }`, deprecated since BullMQ 5.16.0) derive it by **hashing the repeat options** (name, cron `pattern`, `every`, `tz`), while Job Schedulers use the explicit `schedulerId` passed to `upsertJobScheduler(schedulerId, repeatOpts, template)`.
|
|
19
|
+
**Detect:** `upsertJobScheduler(`, `removeJobScheduler(`, `repeat:`, `pattern:`, `every:`, `removeRepeatable(`, `removeRepeatableByKey(`, `getRepeatableJobs(`, `getJobSchedulers(`
|
|
20
|
+
**Ships green, breaks:** Legacy API: change the cron string (or tz, or job name) → the hash changes → a brand-new repeatable is registered while the OLD one keeps firing forever; `removeRepeatable(name, opts)` only works with the EXACT original options (else list with `getRepeatableJobs()` and use `removeRepeatableByKey`). Job Schedulers: re-upserting the SAME `schedulerId` updates in place — but changing the `schedulerId` string recreates the legacy problem. Registration is restart-time-only, so schedulers removed from code persist in Redis indefinitely.
|
|
21
|
+
**Safe change:** Use `upsertJobScheduler` with a stable, never-derived `schedulerId`; change cadence by re-upserting the same id, never by renaming; on any rename, explicitly `removeJobScheduler(oldId)`; audit live state with `getJobSchedulers()` + `getRepeatableJobs()` after deploy.
|
|
22
|
+
|
|
23
|
+
## bullmq-lock-stall-redelivery — CRITICAL · lifecycle-protocol
|
|
24
|
+
**Contract:** The worker promises to finish (or keep renewing the lock on) a job within `lockDuration` (default 30000 ms, auto-renewed at `lockDuration/2` — renewal runs on the event loop); the queue promises not to hand the job to anyone else until then.
|
|
25
|
+
**Detect:** `lockDuration`, `lockRenewTime`, `stalledInterval`, `maxStalledCount`, `on('stalled'`, `concurrency:`
|
|
26
|
+
**Ships green, breaks:** Any CPU-bound section, sync I/O, or GC pause longer than the renewal window blocks lock renewal → the stalled-checker (every `stalledInterval`, default 30000 ms) moves the job back to `wait` → a second worker starts it **while the first is still running it**: emails sent twice, payments charged twice. No test catches this — it needs production-sized payloads. After `maxStalledCount` (default 1) the job fails with `job stalled more than allowable limit`.
|
|
27
|
+
**Safe change:** Size `lockDuration` above worst-case runtime when adding slow steps; keep the processor's event loop breathing (chunk CPU work, `await` regularly) or move heavy jobs to sandboxed processors (separate process = renewal survives CPU blocks); make side effects idempotent (dedup key per `job.id`); alert on the `stalled` event — it means double-processing happened.
|
|
28
|
+
|
|
29
|
+
## bullmq-retry-retention-dlq — HIGH · lifecycle-protocol
|
|
30
|
+
**Contract:** `attempts`/`backoff` on the producer's job options promise the handler will be re-invoked with the same payload on failure; the handler must be idempotent to that promise; the `failed` set is the implicit dead-letter queue, and Redis retains completed/failed jobs per `removeOnComplete`/`removeOnFail`.
|
|
31
|
+
**Detect:** `attempts:`, `backoff:`, `removeOnComplete`, `removeOnFail`, `UnrecoverableError`, `on('failed'`, `defaultJobOptions`
|
|
32
|
+
**Ships green, breaks:** (1) Someone adds `attempts: 5` in producer options — the handler, written for exactly-once, now re-executes partial side effects up to 5×. (2) BullMQ's default keeps **all** completed and failed jobs forever — no `removeOnComplete`/`removeOnFail` means Redis grows until OOM weeks later (auto-removal is lazy: it runs only when a subsequent job finishes, so an idle queue never cleans up). (3) Exhausted retries land in `failed` — the de-facto DLQ nothing monitors; work is lost invisibly. Wrapping `UnrecoverableError` in a plain `Error` during a refactor silently re-enables retries.
|
|
33
|
+
**Safe change:** Set `defaultJobOptions: { removeOnComplete: {age, count}, removeOnFail: {age} }` on every queue; treat any `attempts > 1` change as an idempotency review of the handler; alert on `failed` depth (`getFailedCount()`); preserve `UnrecoverableError` semantics through error-wrapping refactors.
|
|
34
|
+
|
|
35
|
+
## bullmq-queue-name-prefix-rendezvous — HIGH · rendezvous-string
|
|
36
|
+
**Contract:** Producer `new Queue(name, { prefix, connection })` and worker `new Worker(name, fn, { prefix, connection })` rendezvous on the Redis key `{prefix}:{name}:*` (default prefix `bull`) — same name, same prefix, same Redis logical DB, or they never meet.
|
|
37
|
+
**Detect:** `new Queue(`, `new Worker(`, `prefix:`, `QueueEvents(`, `db:` in redis connection opts, `{` hash-tag in prefix
|
|
38
|
+
**Ships green, breaks:** Any mismatch — name typo, one side adding `prefix: 'myapp'`, different `db` index, different Redis instance after a config refactor — and jobs pile up in `wait` forever while the worker idles at zero errors on both sides. Redis Cluster: the prefix must be a hash tag (`prefix: '{myqueue}'`) so all keys land in one slot; producer with `{tag}` + worker without = two disjoint key spaces.
|
|
39
|
+
**Safe change:** Define queue name + prefix once in a shared module; never inline the string; after any connection/prefix refactor, run a canary job end-to-end; on cluster, hash-tag the prefix identically everywhere including dashboards (bull-board with a different prefix shows an empty queue).
|
|
40
|
+
|
|
41
|
+
## redis-pubsub-fire-and-forget — HIGH · rendezvous-string
|
|
42
|
+
**Contract:** `PUBLISH channel` and `SUBSCRIBE channel` / `PSUBSCRIBE pattern` meet on a literal channel string at one instant in time — Redis pub/sub has zero buffering, zero persistence, zero delivery guarantee, and `PUBLISH` returns a receiver-count nobody checks.
|
|
43
|
+
**Detect:** `.publish(`, `.subscribe(`, `.pSubscribe(`, `.psubscribe(`, `.sSubscribe(`, `duplicate()` on redis clients
|
|
44
|
+
**Ships green, breaks:** No subscriber connected (worker restarting, deploy gap) = message silently gone — `publish()` resolves fine, returning `0`, unchecked. Renaming a channel on one side, or editing a `PSUBSCRIBE` glob, silently partitions publisher from subscriber. Redis 7 cluster: plain `SUBSCRIBE` broadcasts cluster-wide but sharded `SSUBSCRIBE`/`SPUBLISH` route by slot — mixing `publish` with `sSubscribe` never meets. node-redis requires a dedicated (`.duplicate()`) connection in subscriber mode.
|
|
45
|
+
**Safe change:** Treat pub/sub as lossy signaling only — anything that must be processed goes to Streams or BullMQ; centralize channel names/patterns in shared constants; pair `publish`↔`subscribe` and `sPublish`↔`sSubscribe`, never cross.
|
|
46
|
+
|
|
47
|
+
## redis-streams-group-offset-identity — CRITICAL · rendezvous-string
|
|
48
|
+
**Contract:** A consumer group's NAME is the durable owner of its offset (`last-delivered-id`) and its pending-entries list (PEL); consumers within it are named too, and un-acked messages belong to the consumer name that read them.
|
|
49
|
+
**Detect:** `xGroupCreate(`, `XGROUP CREATE`, `xReadGroup(`, `xAck(`, `xAutoClaim(`, `xClaim(`, `XGROUP DELCONSUMER`, `MKSTREAM`
|
|
50
|
+
**Ships green, breaks:** Rename the group in code → `XGROUP CREATE` mints a fresh group starting wherever the code says: `$` = silently SKIP everything undelivered (data loss), `0` = REPROCESS the entire retained stream (duplicate side effects) — and the old group's PEL messages are stranded forever. `XGROUP DELCONSUMER` **discards** that consumer's pending entries outright. `XADD` with `MAXLEN` trimming can delete messages a lagging group never consumed.
|
|
51
|
+
**Safe change:** Group names are permanent identifiers — never rename; migrate: create new group, drain old PEL via `XAUTOCLAIM` + ack, then `XGROUP DESTROY`; claim a dead consumer's PEL before any `DELCONSUMER`; monitor `XPENDING` depth and `XINFO GROUPS` lag; size `MAXLEN` against worst-case consumer downtime.
|
|
52
|
+
|
|
53
|
+
## websocket-envelope-mixed-clients — HIGH · serialized-shape
|
|
54
|
+
**Contract:** Server and a MIXED-VERSION population of long-lived clients agree on the message envelope — the `{type, payload}` discriminator values and payload fields — with no per-message negotiation and no redeploy of clients: tabs stay open for days, mobile apps for weeks.
|
|
55
|
+
**Detect:** `JSON.parse(event.data)`, `ws.send(JSON.stringify(`, `switch (msg.type)`, `socket.emit('`, `socket.on('`, `**/ws/**`, `**/realtime/**`
|
|
56
|
+
**Ships green, breaks:** Server renames a `type` value or restructures `payload` → every already-connected old client either throws or — the silent case — hits a `switch` with no matched case and drops the message: the UI just stops updating, no error anywhere. socket.io event names are the same bare-literal rendezvous — a renamed event is silently never delivered. TypeScript types verify nothing about deployed clients.
|
|
57
|
+
**Safe change:** Message types are append-only — add new `type` values, never rename/repurpose; clients MUST ignore unknown types explicitly (with a metric, not a crash); payload fields additive-only until a protocol-version bump negotiated at connect; keep the server accepting old client messages for the longest realistic client lifetime, not the deploy window.
|
|
58
|
+
|
|
59
|
+
## realtime-channel-template-tenant-scope — CRITICAL · trust-invariant
|
|
60
|
+
**Contract:** The channel-name template (`chat:{tenantId}:{sessionId}` — Durable-Object room names, Redis channels, socket.io rooms alike) is built independently by the publisher, the subscriber, AND the subscribe-authorization check; the tenant segment embedded in the string IS the isolation boundary.
|
|
61
|
+
**Detect:** `` `chat:${ ``, `` `${tenantId}:` ``, `.join(':')`, `split(':')`, `io.to(`, `socket.join(`, `idFromName(`, room/channel builder functions
|
|
62
|
+
**Ships green, breaks:** Two directions, both silent. (1) **Availability** — the publisher's template changes while subscribers build the old string: clients subscribe to a channel the server never publishes to; connects succeed, auth passes, zero messages, zero errors. (2) **Isolation** — drop or misorder the tenant segment, or interpolate an unvalidated value: a `sessionId`/`tenantId` containing `:` shifts segments so the string parses as a different tenant's channel — segment injection through the delimiter. `PSUBSCRIBE chat:*` grants cross-tenant reads in one line.
|
|
63
|
+
**Safe change:** One shared `buildChannelName()`/`parseChannelName()` module used by publish, subscribe, AND authz — never three inline template literals; validate segments against `[^:]+` before interpolation; authz parses the ACTUAL requested channel string, never trusts client-supplied segments; template changes are a versioned migration (dual-publish during rollout).
|
|
64
|
+
|
|
65
|
+
## socketio-multinode-adapter — HIGH · config-elsewhere
|
|
66
|
+
**Contract:** `io.to(room).emit(…)` reaching ALL connected clients depends on every node running the same adapter (`@socket.io/redis-adapter` / `@socket.io/redis-streams-adapter`) against the same Redis — and HTTP long-polling fallback depends on load-balancer sticky sessions declared in infra config the app repo never sees.
|
|
67
|
+
**Detect:** `createAdapter(`, `@socket.io/redis-adapter`, `io.adapter(`, `io.to(`, `io.in(`, `transports:`, `allowEIO3`, LB config (`ip_hash`, `sessionAffinity`, ALB stickiness)
|
|
68
|
+
**Ships green, breaks:** Works perfectly with one node in dev. Scale to two nodes without the adapter: broadcasts only reach sockets on the emitting node — a random ~half of users silently miss every message. Sticky sessions missing: the polling handshake round-robins to a node that doesn't know the session → `400 Session ID unknown` → silent reconnect loops, presenting as "flaky realtime". A REST endpoint emitting via a bare `io` instance from a different process (no adapter) publishes to nobody.
|
|
69
|
+
**Safe change:** Wire the adapter before the second node exists; verify with a 2-node compose setup emitting cross-node; declare the sticky-session requirement next to the socket.io code or force `transports: ['websocket']` deliberately; treat `allowEIO3` removal as a client-population question, not a cleanup.
|
|
70
|
+
|
|
71
|
+
## outbound-webhook-contract — CRITICAL · serialized-shape
|
|
72
|
+
**Contract:** The org's emitted webhooks — payload shape, event-type names, signature header name + algorithm, and the shared secret — are consumed by EXTERNAL subscribers you cannot redeploy, coordinate with, or even enumerate.
|
|
73
|
+
**Detect:** `createHmac(`, `timingSafeEqual(`, signature header literals (`X-*-Signature`, `X-Hub-Signature-256`), `event_type`, webhook dispatch tables, `WEBHOOK_SECRET`
|
|
74
|
+
**Ships green, breaks:** Everything here ships green because the other side is off-repo. Rename an event type: subscribers filter on the literal string and silently ignore the new one — their integrations just stop firing. Rotate the secret in one step: every delivery fails verification the instant the env var flips; if the sender retries-then-drops, events are permanently lost. Canonicalization drift is subtler: signing re-serialized JSON instead of the exact raw bytes sent (key order, whitespace, unicode escaping) makes verification fail only for some payloads.
|
|
75
|
+
**Safe change:** Event types and payload fields are append-only; breaking payload changes = a new version delivered per-subscriber, old version supported indefinitely; rotate secrets with an overlap window — sign with BOTH (Stripe/Svix `v1=sig1,v1=sig2` style) until subscribers confirm; sign the exact raw body bytes; include a signed timestamp; never repurpose the header name or algorithm prefix.
|
|
76
|
+
|
|
77
|
+
## sse-resume-and-event-names — MED · lifecycle-protocol
|
|
78
|
+
**Contract:** Server SSE frames and browser `EventSource` agree on two things with no static link: the `id:` field ↔ `Last-Event-ID` resume protocol across auto-reconnects, and the `event:` field name ↔ `addEventListener(name)` dispatch.
|
|
79
|
+
**Detect:** `text/event-stream`, `Last-Event-ID`, `new EventSource(`, `res.write('id:`, `res.write('event:`, `onmessage`, `X-Accel-Buffering`
|
|
80
|
+
**Ships green, breaks:** (1) `EventSource` auto-reconnects invisibly and sends `Last-Event-ID`; a server that never assigned `id:` — or ignores the header — silently drops every event from the gap while the client believes it is live. (2) Adding an `event: order-update` field to previously-unnamed frames reroutes dispatch: `onmessage` fires ONLY for unnamed frames, so existing clients silently stop receiving everything. Infra trap: a buffering reverse proxy (nginx default) delays events unboundedly until `X-Accel-Buffering: no` is set.
|
|
81
|
+
**Safe change:** Assign monotonic `id:` from day one and implement replay from `Last-Event-ID` (or send an explicit `snapshot` event on connect); never add/rename the `event:` field on an existing stream; keep event names in a shared constant when the client is in-repo; verify proxy buffering headers whenever the route moves.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Pack: identity, auth, tenancy
|
|
2
|
+
|
|
3
|
+
Load when: the touch list exhibits jose (`jwtVerify(`), Better Auth (`betterAuth(`, `auth.api.`), session cookies, CSRF middleware, OAuth callbacks/`redirect_uri`, tenant scoping (`tenantId`, `organizationId`), password hashing, or auth middleware mounts. This area skews CRITICAL: the defining hazard is a silent auth bypass or data disclosure, not a loud error.
|
|
4
|
+
|
|
5
|
+
## jose-jwtverify-claims-opt-in — CRITICAL · trust-invariant
|
|
6
|
+
**Contract:** The token minter scopes tokens with `iss`/`aud`, and every verifier must pass matching `issuer`/`audience` options to `jwtVerify` — jose 6 checks signature, `exp`, `nbf` by default but NOT `iss`, `aud`, `sub`, or `typ` unless the option is passed.
|
|
7
|
+
**Detect:** `jwtVerify(` without `issuer:`/`audience:` in the same options object, `jwtVerify(token, secret)` two-arg calls, missing `algorithms:` where the key is an HMAC secret (`Uint8Array`), `clockTolerance`, `maxTokenAge`
|
|
8
|
+
**Ships green, breaks:** A valid token minted for a *different* service or audience by the same issuer/key verifies successfully — cross-service token replay; with a shared HMAC secret and no `algorithms: ['HS256']` allowlist, any HS* alg the secret supports is accepted. Everything compiles; the token "verifies".
|
|
9
|
+
**Safe change:** Always pass `{ issuer, audience, algorithms }` to `jwtVerify`; treat a bare two-arg call as a finding. Add `clockTolerance: '30s'` for cross-host skew. When adding a new consumer service, mint tokens with a distinct `aud` and enforce it there.
|
|
10
|
+
|
|
11
|
+
## jwt-identity-keyed-on-iss-plus-sub — CRITICAL · trust-invariant
|
|
12
|
+
**Contract:** `sub` is unique only *per issuer* (RFC 7519); application user lookup must key on the pair `(iss, sub)`, never `sub` alone.
|
|
13
|
+
**Detect:** `payload.sub` used as a sole DB lookup key, multi-IdP config (multiple `issuer` values, per-tenant IdPs, `createRemoteJWKSet` called with more than one URL)
|
|
14
|
+
**Ships green, breaks:** With two identity providers (or per-tenant issuers), IdP B can mint `sub` values colliding with IdP A's users — a tenant-controlled IdP issues `sub: <victim-sub>` and logs in as the victim. Single-IdP tests never expose it.
|
|
15
|
+
**Safe change:** Store external identity as a composite `(issuer, subject)` unique key (mirror of Better Auth's `(providerId, accountId)` on `account`); grep every login path for `sub`-only lookups; adding a second IdP is the trigger event for this audit.
|
|
16
|
+
|
|
17
|
+
## jose-remote-jwks-rotation-cache — HIGH · lifecycle-protocol
|
|
18
|
+
**Contract:** The IdP's key-rotation schedule and every verifier's `createRemoteJWKSet` cache must follow the publish-first protocol: jose 6 re-fetches the JWKS when a token's `kid` matches no cached key, but at most once per `cooldownDuration` (default 30000 ms), and refreshes at most every `cacheMaxAge` (default 600000 ms = 10 min).
|
|
19
|
+
**Detect:** `createRemoteJWKSet(`, `cooldownDuration`, `cacheMaxAge`, IdP rotation jobs, `JWKSNoMatchingKey`, `ERR_JWKS_NO_MATCHING_KEY`
|
|
20
|
+
**Ships green, breaks:** IdP starts signing with a new key *before* publishing it → every verifier throws `JWKSNoMatchingKey`, and the 30 s cooldown suppresses re-fetch so the outage comes in waves; removing the old key from the JWKS while its tokens are still live mass-invalidates sessions; each process instance caches independently, so rollouts fail partially.
|
|
21
|
+
**Safe change:** Rotate as: publish new key to JWKS → wait > `cacheMaxAge` → start signing with new `kid` → keep old key published until the longest-lived old token expires; never sign-then-publish; keep `kid` on every JWK and JWS header.
|
|
22
|
+
|
|
23
|
+
## better-auth-schema-dual-authority — CRITICAL · dual-authority
|
|
24
|
+
**Contract:** Better Auth's CLI (`npx @better-auth/cli generate` / `migrate`) owns the shape of the `user`, `session`, `account`, `verification` tables (camelCase columns: `session.expiresAt` — not `expires` — `session.token`, `account.providerId`, `account.accountId`); your ORM migration tool is a second writer over the same tables.
|
|
25
|
+
**Detect:** `@better-auth/cli`, `drizzle-kit push`, `drizzle-kit generate`, auth tables present in DB but absent from `schema.ts`, `modelName`/`fields` remapping in `betterAuth(` config
|
|
26
|
+
**Ships green, breaks:** `drizzle-kit push` diffing against a schema file that lacks the auth tables will *drop them* (with `--force`/CI, no prompt) — every user, session, and OAuth link destroyed; adding a Better Auth plugin (admin, organization, twoFactor) without re-running `generate`/`migrate` leaves its columns (`impersonatedBy`, `role`, `banned`, `activeOrganizationId`) missing, so only that plugin's runtime paths error while core login still works.
|
|
27
|
+
**Safe change:** After any `betterAuth(` config or plugin change, re-run `npx @better-auth/cli generate` and diff into the ORM schema; keep auth tables in the ORM schema file so push never sees them as deleted; column renames only via `fields`/`modelName` config, never raw SQL.
|
|
28
|
+
|
|
29
|
+
## better-auth-session-token-not-a-jwt — HIGH · serialized-shape
|
|
30
|
+
**Contract:** The `better-auth.session_token` cookie carries an opaque signed token (`<token>.<hmac>`) whose first half is the `session.token` DB row key — it is NOT a JWT access/refresh pair, and every other service must validate it by calling `auth.api.getSession({ headers })`, not by decoding.
|
|
31
|
+
**Detect:** `jwtVerify`/`decodeJwt`/`atob(` applied to the session cookie, `session.cookieCache`, `session_data`, `getSessionCookie(`
|
|
32
|
+
**Ships green, breaks:** A sibling service that "verifies" the cookie as a JWT fails for all users; with `session.cookieCache: { enabled: true, maxAge }` the session payload is served from the signed `session_data` cookie, so `revokeSession`/user-ban does NOT take effect until the cookie cache expires — logout/ban tests without cookieCache pass, production revocation lags. `getSessionCookie()` only checks cookie *existence*, never validity — using it as an auth gate is a bypass.
|
|
33
|
+
**Safe change:** Cross-service auth goes through `getSession` against the auth service (or add the `jwt` plugin and verify those JWTs with jose); if enabling `cookieCache`, bound `maxAge` by your revocation SLA; treat `getSessionCookie` as an optimistic-redirect hint only.
|
|
34
|
+
|
|
35
|
+
## better-auth-cookie-name-rendezvous — HIGH · rendezvous-string
|
|
36
|
+
**Contract:** Every consumer that reads the session cookie by name (edge middleware, nginx, a second service) must compute the same name Better Auth writes: `{cookiePrefix}.session_token` (default prefix `better-auth`), which becomes `__Secure-better-auth.session_token` when cookies are secure (production / `advanced.useSecureCookies: true`).
|
|
37
|
+
**Detect:** literal `better-auth.session_token`, `__Secure-`, `advanced.cookiePrefix`, `advanced.crossSubDomainCookies`, `getSessionCookie(request, { cookieName`
|
|
38
|
+
**Ships green, breaks:** Code reading `cookies['better-auth.session_token']` works in dev (http, no prefix) and silently sees no cookie in production because the name is `__Secure-`-prefixed — everyone appears logged out on the consumer side only; changing `cookiePrefix` orphans every hardcoded reader. `crossSubDomainCookies` is **disabled by default** — a sibling subdomain service never receives the cookie until `advanced.crossSubDomainCookies: { enabled: true, domain }` is set.
|
|
39
|
+
**Safe change:** Never hardcode the cookie name — use `getSessionCookie(request, { cookieName, cookiePrefix })` with the same config as the server; when changing `cookiePrefix`/`crossSubDomainCookies`, grep all services for the old literal; test cookie reads under https/production mode, not just dev.
|
|
40
|
+
|
|
41
|
+
## better-auth-trusted-origins-allowlist — CRITICAL · config-elsewhere
|
|
42
|
+
**Contract:** `trustedOrigins` (default: only the `baseURL` origin) is the single allowlist gating both the Origin-header CSRF check and every client-supplied `callbackURL`/redirect target.
|
|
43
|
+
**Detect:** `trustedOrigins`, wildcard entries `https://*.` / `**`, `callbackURL` passed from client code, new frontend domains not present in the auth config
|
|
44
|
+
**Ships green, breaks:** The loud half: a new frontend origin fails with 403s. The silent half: someone "fixes" it with `https://*.example.com` — now any subdomain (stale CNAME takeover, user-content subdomains, preview deploys) can drive login CSRF and receive `callbackURL` redirects carrying fresh sessions; `**` additionally crosses `/`. The wildcard ships green because every legitimate flow still works.
|
|
45
|
+
**Safe change:** Enumerate origins exactly; if wildcards are unavoidable, scope to a dedicated non-user-controllable parent domain; review `trustedOrigins` whenever a domain is added/retired.
|
|
46
|
+
|
|
47
|
+
## session-cookie-attribute-downgrade — CRITICAL · trust-invariant
|
|
48
|
+
**Contract:** The session cookie's `HttpOnly` + `Secure` + `SameSite=Lax|Strict` + narrowest-possible `Domain` collectively are the CSRF/theft defense; any relaxation keeps login working while removing the guarantee.
|
|
49
|
+
**Detect:** `sameSite: 'none'`, `httpOnly: false`, `domain: '.` (parent-domain widening), `secure: false`, proxy configs rewriting `Set-Cookie`
|
|
50
|
+
**Ships green, breaks:** Switching to `SameSite=None` (to make an iframe work) silently re-enables classic CSRF on every state-changing endpoint unless a token defense exists; widening `Domain` to `.example.com` hands the session to every subdomain including compromised/user-content ones; `httpOnly: false` (added so JS can check "am I logged in") turns any XSS into full session theft. All ship green — the cookie still authenticates every legitimate request.
|
|
51
|
+
**Safe change:** Treat any diff touching cookie attributes as security review; `SameSite=None` requires a compensating CSRF token on the same PR; keep `Domain` host-only unless cross-subdomain is a deliberate product decision; expose login state via an endpoint, never by de-HttpOnly-ing the session cookie.
|
|
52
|
+
|
|
53
|
+
## csrf-double-submit-lockstep — HIGH · rendezvous-string
|
|
54
|
+
**Contract:** In double-submit CSRF, the CSRF cookie must be JS-readable (NOT HttpOnly) while the session cookie must be HttpOnly, and the frontend's header name must match the backend's reader — axios defaults `xsrfCookieName: 'XSRF-TOKEN'` / `xsrfHeaderName: 'X-XSRF-TOKEN'`.
|
|
55
|
+
**Detect:** `XSRF-TOKEN`, `X-XSRF-TOKEN`, `xsrfCookieName`, `xsrfHeaderName`, `withXSRFToken`, CSRF middleware with a skip-when-absent branch, blanket `httpOnly: true` cookie sweeps
|
|
56
|
+
**Ships green, breaks:** The two cookies are routinely inverted: a "hardening" sweep sets HttpOnly on everything — the frontend can't read the CSRF cookie, and if the middleware validates only *when the header is present*, requests sail through with zero protection, silently; renaming the header on one side has the same fail-open effect. axios only auto-attaches the XSRF header same-origin unless `withXSRFToken` is set — cross-origin SPAs silently send no token.
|
|
57
|
+
**Safe change:** CSRF middleware must *reject* state-changing requests when the token is absent, never skip; pin cookie+header names in one shared constant consumed by both sides; assert in an integration test that a request without the header is refused.
|
|
58
|
+
|
|
59
|
+
## oauth-redirect-uri-exact-match — CRITICAL · trust-invariant
|
|
60
|
+
**Contract:** The OAuth authorization-server allowlist and the client's `redirect_uri` agree by *exact string match*; `state` (and `nonce`/PKCE for OIDC) must be both generated and verified — the flow completes identically when verification is dropped.
|
|
61
|
+
**Detect:** `redirect_uri`, `startsWith(`/prefix/wildcard matching in redirect validation, `genericOAuth` plugin configs, hand-rolled `/callback` handlers, `state` generated but never compared, `code_verifier`/`code_challenge` present on only one leg
|
|
62
|
+
**Ships green, breaks:** Prefix or wildcard matching lets `https://app.example.com.evil.com` or a path-traversal callback receive the authorization code — code exfiltration, full account access; dropping the `state` comparison (or the PKCE `code_verifier` check) fails open: every legitimate login still works, but login-CSRF/code-injection is silently enabled. Better Auth's built-in providers handle this — the hazard is `genericOAuth` configs and hand-rolled flows.
|
|
63
|
+
**Safe change:** Register and compare `redirect_uri` byte-for-byte (scheme, host, port, path); generate `state` bound to the session and *reject* on mismatch or absence; require PKCE S256 end-to-end; test the negative paths (tampered state, missing verifier), not just the happy path.
|
|
64
|
+
|
|
65
|
+
## account-linking-verified-email-only — CRITICAL · trust-invariant
|
|
66
|
+
**Contract:** External identities key on `(providerId, accountId)` (Better Auth's `account` table), and auto-linking a new OAuth identity to an existing user by email is safe only when the provider attests the email as verified — Better Auth's default links only on verified-email match; `trustedProviders` bypasses that check and `allowDifferentEmails: true` (default `false`) widens it further.
|
|
67
|
+
**Detect:** `account.accountLinking`, `trustedProviders`, `allowDifferentEmails`, custom provisioning doing `findUserByEmail` on OAuth callback, lookups keyed on profile email instead of `(providerId, accountId)`
|
|
68
|
+
**Ships green, breaks:** Adding a provider to `trustedProviders` (or hand-rolling link-by-email) means an attacker registers at that provider with the victim's email *unverified*, signs in, and is silently linked into the victim's account — full takeover; every legitimate login flow still works, so nothing surfaces in testing.
|
|
69
|
+
**Safe change:** Keep identity lookup on `(providerId, accountId)`; only list providers in `trustedProviders` that contractually verify emails; leave `allowDifferentEmails` false unless linking is an explicit user-initiated, authenticated action.
|
|
70
|
+
|
|
71
|
+
## tenant-id-non-spoofable-source — CRITICAL · trust-invariant
|
|
72
|
+
**Contract:** The tenant identifier used to scope every query must come from a server-validated source — a verified JWT claim, the session (e.g. Better Auth organization plugin's `session.activeOrganizationId`), or server-resolved subdomain — never from `req.body`, `req.query`, or a client-set header; and every data access must actually include the scope.
|
|
73
|
+
**Detect:** `req.body.tenantId`, `req.query.tenantId`, `req.headers['x-tenant`, queries by bare PK in multi-tenant repos (`WHERE id = ?` without `tenant_id`/`organizationId`), new route files not using the shared tenant middleware
|
|
74
|
+
**Ships green, breaks:** Two failure modes, both silent: (1) tenant read from the request → any authenticated user sets another tenant's id and reads/writes their data; (2) the new-endpoint failure — a handler queries by UUID primary key alone, works perfectly in every test (ids are globally unique), and is a cross-tenant IDOR in production. Neither throws, lints, or fails a test.
|
|
75
|
+
**Safe change:** One `requireTenant` middleware derives `req.tenantId` from session/claim only — delete all request-sourced fallbacks; make the repository layer take `tenantId` as a required parameter so unscoped queries can't be expressed; review every new endpoint for the scope, not just the auth check.
|
|
76
|
+
|
|
77
|
+
## express5-auth-middleware-mount-order — HIGH · lifecycle-protocol
|
|
78
|
+
**Contract:** In Express 5, `app.use(authMiddleware)` protects only routes and routers mounted *after* it in registration order, and per-identity rate limiting (`keyGenerator: req => req.user.id`) requires auth to have already run.
|
|
79
|
+
**Detect:** `app.use(` ordering in the composition root, router mounts above the auth `app.use`, `keyGenerator` referencing `req.user`, `app.use('*'` patterns (path-to-regexp v8: bare `*` is invalid — use `/*splat`)
|
|
80
|
+
**Ships green, breaks:** A new `app.use('/api/reports', reportsRouter)` line added above the auth mount ships green — every endpoint works in manual testing (the dev is logged in anyway) — but is fully unauthenticated; a rate limiter mounted before auth with an identity `keyGenerator` sees `req.user === undefined`, collapsing all clients into one shared `undefined` bucket: global lockout under load and per-user limits silently gone.
|
|
81
|
+
**Safe change:** Keep one composition root with the invariant order: parsers → auth → identity-keyed rate limit → routers → error handler; mount new routers only below the auth line; give identity-keyed limiters a hard failure (throw) when `req.user` is absent instead of a fallback key.
|
|
82
|
+
|
|
83
|
+
## password-hash-format-migration — CRITICAL · serialized-shape
|
|
84
|
+
**Contract:** Stored password hashes (Better Auth default: scrypt, in `account.password` for `providerId: 'credential'`) are a persisted serialized format that every future `verify` implementation must still accept.
|
|
85
|
+
**Detect:** `emailAndPassword.password: { hash, verify }` overrides, argon2/bcrypt param changes, hash-format prefixes in `account.password` vs what `verify` parses, absence of rehash-on-login logic
|
|
86
|
+
**Ships green, breaks:** Swapping the algorithm (scrypt → argon2) or tightening params without a legacy path makes `verify` return `false` for every pre-existing user — no error is thrown, new signups work, fresh-user tests pass, and the user base is silently locked out over the following weeks as sessions expire.
|
|
87
|
+
**Safe change:** New `verify` must detect the old format by prefix and accept it; on successful legacy verify, rehash with the new params and update the row; keep the legacy branch until no old-format hashes remain (measure, don't guess); never bulk-invalidate.
|
|
88
|
+
|
|
89
|
+
## impersonation-dual-identity-audit — HIGH · trust-invariant
|
|
90
|
+
**Contract:** With Better Auth's admin plugin, an impersonated session carries BOTH identities — `session.userId` is the target, `session.impersonatedBy` is the acting admin — and every audit/attribution consumer must read both; the impersonated session auto-expires after 1 hour by default (`impersonationSessionDuration`, seconds).
|
|
91
|
+
**Detect:** `impersonateUser`, `stopImpersonating`, `impersonatedBy`, `impersonationSessionDuration`, audit code reading only `session.userId`, session payloads forwarded downstream without `impersonatedBy`
|
|
92
|
+
**Ships green, breaks:** Audit logs, "last modified by" fields, and downstream services that record `session.userId` silently attribute admin actions to the *victim* user — the audit trail is wrong precisely when it matters; custom session serialization that drops `impersonatedBy` erases the distinction entirely. Impersonation itself works flawlessly, so nothing surfaces.
|
|
93
|
+
**Safe change:** Every audit write records `actorId = session.impersonatedBy ?? session.userId` plus `onBehalfOf` when they differ; propagate `impersonatedBy` in any session payload crossing a service boundary; after adding the admin plugin, re-run the Better Auth CLI so the `impersonatedBy` column exists (see schema dual-authority entry).
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Pack: package/workspace management, build config, CI, runtime process
|
|
2
|
+
|
|
3
|
+
Load when: the touch list exhibits `package.json` (deps, `exports`, scripts, `packageManager`), lockfiles, `.env*` files, tsconfig `paths`, Dockerfiles/compose, `.github/workflows/`, pino logger config, or Express servers behind proxies.
|
|
4
|
+
|
|
5
|
+
## env-var-reader-vs-declaration — HIGH · config-elsewhere
|
|
6
|
+
**Contract:** Every `process.env.X` read in code must have X declared in `.env.example`, each `.env.*`, CI `env:`/secrets, and every deploy dashboard — the reader and the declarations share only the literal name.
|
|
7
|
+
**Detect:** `process.env\.[A-Z_]+`, `.env.example`, `.env*`, `env:` / `secrets\.` in `.github/workflows/*.yml`, `loadEnv(`, `dotenv`
|
|
8
|
+
**Ships green, breaks:** Undefined var is `undefined`, not an error — `|| defaultValue` fallbacks silently run prod against the default; env values are ALWAYS strings, so `if (process.env.FEATURE_ENABLED)` is truthy for `"false"` and `"0"`; dotenv loads only locally, so a var present in `.env` but absent from the deploy dashboard works on every laptop and fails only in prod.
|
|
9
|
+
**Safe change:** Add the key to `.env.example` + every deploy target BEFORE merging the reader; validate all env at boot with a zod schema and crash on missing/malformed (fail-fast, no silent defaults); parse booleans explicitly (`=== 'true'`); grep workflows and dashboards when renaming a key.
|
|
10
|
+
|
|
11
|
+
## vite-client-env-prefix-gate — CRITICAL · trust-invariant
|
|
12
|
+
**Contract:** The `VITE_` prefix is the only gate deciding whether an env var is inlined verbatim into the public JS bundle; server secrets rely on NOT having the prefix.
|
|
13
|
+
**Detect:** `import.meta.env.VITE_`, `envPrefix` in `vite.config.*`, `define:` blocks, `VITE_` keys in `.env*`
|
|
14
|
+
**Ships green, breaks:** Prefixing a secret (`VITE_API_KEY`) ships it string-searchable in `dist/assets/*.js` with zero warning; `import.meta.env.VITE_*` is statically replaced at BUILD time — rotating the value in a deploy dashboard changes nothing until rebuild, and a build-once/promote-across-envs pipeline bakes the build env's values into every environment; widening `envPrefix` silently exposes every matching var; `define:` entries bypass the prefix gate entirely.
|
|
15
|
+
**Safe change:** Before adding `VITE_` to any var, confirm it is public; audit `dist/` with `grep -r "<secret>" dist/` after build; treat `envPrefix` and `define` changes as security-review items; never promote a client bundle across environments whose `VITE_` values differ.
|
|
16
|
+
|
|
17
|
+
## lockfile-manifest-sync — HIGH · generated-artifact
|
|
18
|
+
**Contract:** `pnpm-lock.yaml` (or `package-lock.json`/`yarn.lock`) is a generated artifact that must move in the same commit as any `package.json` dependency, `overrides`/`resolutions`, or `pnpm.overrides` change.
|
|
19
|
+
**Detect:** `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock` (more than one present = hazard), `"overrides"`, `"resolutions"`, `"pnpm": { "overrides"` in `package.json`
|
|
20
|
+
**Ships green, breaks:** Hand-editing `package.json` without running install works locally (bare `pnpm install` silently updates the lock) then fails CI with `ERR_PNPM_OUTDATED_LOCKFILE` — pnpm defaults `--frozen-lockfile` to true when `CI=true`; adding an `overrides` entry without re-locking means the vulnerable version stays pinned and installed while review shows the override "applied"; two lockfiles in one repo means detect-from-lockfile tooling picks nondeterministically and installs a different tree per machine; CI using `npm install` instead of `npm ci` silently rewrites resolutions.
|
|
21
|
+
**Safe change:** Run the detected manager's install and commit the lockfile with the manifest change; keep exactly one lockfile (delete strays); CI installs use `pnpm install --frozen-lockfile` / `npm ci` / `yarn install --immutable`; after any `overrides` change, verify the resolved version with `pnpm why <pkg>`.
|
|
22
|
+
|
|
23
|
+
## pnpm-strict-layout-phantom-imports — HIGH · rendezvous-string
|
|
24
|
+
**Contract:** Every module specifier imported by a package must appear in THAT package's `dependencies` — the import string and the manifest meet only through node_modules layout, and pnpm's symlinked layout exposes only declared deps.
|
|
25
|
+
**Detect:** `import .* from ['"]` specifiers absent from the nearest `package.json` `dependencies`, `workspace:` protocol entries, `.npmrc` `public-hoist-pattern`/`shamefully-hoist`, `node-linker`
|
|
26
|
+
**Ships green, breaks:** An undeclared transitive import (phantom dep) typechecks (types hoisted or ambient) and works under npm's flat hoisting, then throws `ERR_MODULE_NOT_FOUND` at runtime under pnpm — or vice versa when a repo migrates managers; a cross-package workspace import without a `workspace:*` dep entry works via stale symlinks locally and breaks on fresh CI install; relying on `shamefully-hoist=true` masks all of this until someone removes it.
|
|
27
|
+
**Safe change:** Declare every imported package in the importer's own `package.json` (workspace siblings as `"@nurix/x": "workspace:*"`); never widen `public-hoist-pattern` to fix a missing-module error — add the dep; verify with a fresh `pnpm install` on a clean clone.
|
|
28
|
+
|
|
29
|
+
## package-exports-subpath-map — HIGH · rendezvous-string
|
|
30
|
+
**Contract:** Consumers' import specifiers (`@nurix/components/button`) must match literal keys in the package's `exports` map; the `files` allowlist and `types` conditions must cover the same paths.
|
|
31
|
+
**Detect:** `"exports"` and `"files"` in `package.json`, `from ['"]@nurix/[^'"]+/`, `"moduleResolution"` in `tsconfig.json`
|
|
32
|
+
**Ships green, breaks:** Any subpath not mapped in `exports` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime — and tsc under `moduleResolution: "node10"` ignores `exports` entirely, so types resolve while Node throws (use `"bundler"`/`"nodenext"` to make tsc see the real contract); adding a new component file without an `exports` entry publishes fine and breaks every consumer import; a built `dist/` path referenced by `exports` but missing from the `files` allowlist installs cleanly and fails only on first import of the published tarball.
|
|
33
|
+
**Safe change:** Add the `exports` subpath (with `types` condition first in each condition block) in the same change as the new file; confirm `files` includes everything `exports` references; check the shipped surface with `npm pack --dry-run`; treat removing/renaming an exports key as a breaking change for all consumers.
|
|
34
|
+
|
|
35
|
+
## private-registry-auth-split — HIGH · config-elsewhere
|
|
36
|
+
**Contract:** `@nurix/*` installs authenticate via an ambient read token in `~/.npmrc` locally and via `NODE_AUTH_TOKEN` + `setup-node`'s `registry-url` in CI — two disconnected declarations of the same credential.
|
|
37
|
+
**Detect:** `@nurix:registry=`, `_authToken` in `.npmrc`, `registry-url:` in workflows, `NODE_AUTH_TOKEN` in step `env:`
|
|
38
|
+
**Ships green, breaks:** The npm registry returns **404 (not 401)** for restricted packages without valid auth — CI failures read as "package doesn't exist," which tempts agents into substituting public packages (forbidden here); `setup-node` with `registry-url` writes an `.npmrc` referencing `${NODE_AUTH_TOKEN}` that is resolved at INSTALL time, so the token must be in the `env:` of the step running `pnpm install`/`npm publish`, not on the setup-node step; local installs succeed off `~/.npmrc` so the CI gap is invisible until the pipeline runs.
|
|
39
|
+
**Safe change:** In CI, pair `setup-node` `registry-url: https://registry.npmjs.org` with `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` on every step touching the registry; on a 404 for `@nurix/*`, diagnose auth first — never swap packages; keep `publishConfig.access: restricted` on every `@nurix` package.
|
|
40
|
+
|
|
41
|
+
## publish-pipeline-artifact-and-loop — HIGH · lifecycle-protocol
|
|
42
|
+
**Contract:** CI publish sequencing: `prepack`/`prepublishOnly` builds the shipped artifact, the version-bump commit carries `[skip ci]`, `npm version` runs `--no-git-tag-version` in monorepo subdirs, and workspace deps are rewritten at pack time.
|
|
43
|
+
**Detect:** `"prepack"`, `"prepublishOnly"` in `package.json`, `npm version` in workflows, `[skip ci]` in bump-commit messages, `workspace:` in `dependencies`
|
|
44
|
+
**Ships green, breaks:** No `prepack` → the published tarball carries whatever stale `dist/` was on the runner (or none, silently trimmed by `files`); a bump commit without `[skip ci]` pushed to the trigger branch re-fires the publish workflow — infinite loop or a duplicate publish dying with `E403 cannot publish over previously published version`; `npm version` in a subdirectory of the git root silently skips the commit+tag step, so tag automation quietly stops; publishing a pnpm workspace package with `npm publish` ships literal `workspace:*` in dependencies — every consumer install fails with `EUNSUPPORTEDPROTOCOL` (only `pnpm publish`/`pnpm pack` rewrites the protocol to real versions).
|
|
45
|
+
**Safe change:** Build in `prepack` so published always matches source; `npm version --no-git-tag-version`, then commit with `[skip ci]` and tag explicitly (`git push origin HEAD:<ref> --follow-tags`); always publish pnpm workspaces with `pnpm publish`; verify content with `npm pack --dry-run` in CI before publish.
|
|
46
|
+
|
|
47
|
+
## tsconfig-paths-vs-bundler-alias — HIGH · dual-authority
|
|
48
|
+
**Contract:** tsconfig `compilerOptions.paths` (what the typechecker resolves) and Vite `resolve.alias` (what the bundle resolves) independently define the same aliases and must stay identical.
|
|
49
|
+
**Detect:** `"paths"` in `tsconfig*.json`, `resolve:` `alias` in `vite.config.*`, `vite-tsconfig-paths`
|
|
50
|
+
**Ships green, breaks:** `paths` never rewrites emitted JS — code compiled with tsc alone typechecks, then Node throws `ERR_MODULE_NOT_FOUND` on the alias at runtime; Vite does NOT read tsconfig `paths` (needs `vite-tsconfig-paths` or a duplicated `resolve.alias`), so an alias added on one side only means typecheck passes while the build/tests resolve a different file — worst case the two point at different targets (`src/` vs `dist/`) and the code you typechecked is not the code you shipped; the same drift applies to Vitest, which uses Vite resolution, not tsc.
|
|
51
|
+
**Safe change:** Single-source aliases via `vite-tsconfig-paths` (tsconfig as authority) or a script that derives one from the other; when adding an alias, update both in one commit and run both `tsc --noEmit` and `vite build`; for Node-run (unbundled) code, avoid `paths` entirely — use package subpath `imports` (`#alias`) which both sides honor.
|
|
52
|
+
|
|
53
|
+
## corepack-packagemanager-version-drift — MED · config-elsewhere
|
|
54
|
+
**Contract:** `packageManager` and `engines` in `package.json` declare the pnpm/Node versions, but the versions actually running in CI, Docker, and laptops are set independently (setup-node/action-setup inputs, `FROM node:` tags, whatever's installed).
|
|
55
|
+
**Detect:** `"packageManager"`, `"engines"` in `package.json`, `corepack enable`, `node-version:` in workflows, `FROM node:` in Dockerfiles, `engine-strict` in `.npmrc`
|
|
56
|
+
**Ships green, breaks:** `packageManager` is enforced ONLY when corepack is active — Node 22 bundles corepack disabled by default and newer Node lines drop it from the distribution, and `setup-node` never enables it — so a dev on an older pnpm regenerates the committed lockfile at a different `lockfileVersion` (wholesale churn, then `ERR_PNPM_OUTDATED_LOCKFILE` in CI); npm ignores `engines` entirely unless `engine-strict=true`, so a newer-Node-only syntax feature passes local dev and crashes the Node-22 Docker runtime; `pnpm/action-setup` errors on "Multiple versions of pnpm specified" when its `version` input contradicts `packageManager`.
|
|
57
|
+
**Safe change:** Treat `packageManager` as the single source: `corepack enable` in Dockerfiles/dev setup, omit `version` from `pnpm/action-setup` so it reads the field; bump Node in `engines`, workflows `node-version`, and `FROM node:` together; set `engine-strict=true` in `.npmrc` to make drift loud.
|
|
58
|
+
|
|
59
|
+
## gha-workflow-repo-rendezvous — HIGH · rendezvous-string
|
|
60
|
+
**Contract:** Workflows reference repo internals purely by literal strings — npm script names in `run:`, secrets/vars by name, `paths:` filters by directory — with no static link to what they name.
|
|
61
|
+
**Detect:** `run: (npm|pnpm|yarn) (run )?[a-z:-]+` in `.github/workflows/*.yml`, `secrets\.[A-Z_]+`, `vars\.[A-Z_]+`, `paths:` / `paths-ignore:` filters
|
|
62
|
+
**Ships green, breaks:** A referenced secret that doesn't exist interpolates to **empty string with no error** — deploys run with a blank API key and publish steps fail with a misleading 404/403 far downstream; renaming a `package.json` script breaks every `run:` line only when that workflow next executes (possibly a rarely-fired release workflow, weeks later); moving `packages/cli/` to a new path while the publish workflow filters `paths: ['packages/cli/**']` means the publish job silently never triggers again — the package just stops shipping; `node-version` drifting from `engines`/Dockerfile builds with a different Node than production runs.
|
|
63
|
+
**Safe change:** When renaming scripts, secrets, or moving package dirs, grep `.github/workflows/` for the old literal; assert required secrets non-empty at job start (`if [ -z "$TOKEN" ]; then exit 1; fi`); after moving a path-filtered directory, push a trivial change inside it and confirm the workflow fires.
|
|
64
|
+
|
|
65
|
+
## docker-arg-env-boundary — CRITICAL · config-elsewhere
|
|
66
|
+
**Contract:** Dockerfile `ARG` exists only at build time and `ENV` persists into the running container; app code reading `process.env.X` at runtime sees only `ENV` (or orchestrator-injected) values, and both are recorded in inspectable image metadata.
|
|
67
|
+
**Detect:** `^ARG `, `^ENV ` in Dockerfiles, `--build-arg` in workflows/compose, `_authToken`/`NPM_TOKEN`/`NODE_AUTH_TOKEN` near `RUN.*install`
|
|
68
|
+
**Ships green, breaks:** A value declared only as `ARG` builds fine but is `undefined` in `process.env` at runtime — config appears set in the Dockerfile yet the app runs on defaults; any `ARG` consumed by a `RUN` (e.g. `--build-arg NPM_TOKEN=` for the `@nurix` install) is permanently visible in `docker history` of that stage, and secrets put in `ENV` are readable via `docker inspect` by anyone who can pull the image; multi-stage copies quietly drop `ENV` set in earlier stages.
|
|
69
|
+
**Safe change:** Runtime config via `ENV` or orchestrator env, never bare `ARG`; registry tokens via BuildKit `RUN --mount=type=secret,id=npmrc,target=/root/.npmrc pnpm install` (never `--build-arg`); audit with `docker history --no-trunc` and `docker inspect` before pushing an image containing credentials.
|
|
70
|
+
|
|
71
|
+
## pid1-signal-delivery — HIGH · lifecycle-protocol
|
|
72
|
+
**Contract:** The container's PID 1 must actually receive SIGTERM for the app's graceful-shutdown handler (`server.close()`, draining) to run before the orchestrator's SIGKILL.
|
|
73
|
+
**Detect:** shell-form `ENTRYPOINT node ...` / `CMD npm start` (no JSON array) in Dockerfiles, `CMD ["npm"`, absence of `process.on('SIGTERM'`, `init:` in compose, `--init`
|
|
74
|
+
**Ships green, breaks:** Shell-form ENTRYPOINT/CMD makes `/bin/sh -c` PID 1, and sh does not forward SIGTERM to the node child — `docker stop` waits the default 10s then SIGKILLs, so shutdown hooks never run and in-flight requests are dropped on every single deploy while everything looks healthy; `CMD ["npm","start"]` has the same defect (npm does not reliably forward signals to its child); the failure is invisible in logs because the process dies before it can log.
|
|
75
|
+
**Safe change:** Exec form running node directly: `ENTRYPOINT ["node","dist/index.js"]`; register `SIGTERM`/`SIGINT` handlers that `server.close()` and exit; add `--init`/tini (or compose `init: true`) for zombie reaping; verify with `docker stop` and confirm the shutdown log line appears in under 10s.
|
|
76
|
+
|
|
77
|
+
## pino-redact-and-field-name-rendezvous — CRITICAL · rendezvous-string
|
|
78
|
+
**Contract:** pino `redact` paths, serializer keys, and downstream dashboard/alert queries all pin the EXACT field names your code passes to the logger — nothing checks they still match.
|
|
79
|
+
**Detect:** `redact:` in pino init, `stdSerializers`, `errorKey`, `logger\.(info|error|warn)\(`, field names referenced in Grafana/Datadog/alert configs
|
|
80
|
+
**Ships green, breaks:** `redact: ['req.headers.authorization']` stops matching the moment the field is renamed or re-nested — the secret/PII logs in cleartext with zero warning (fast-redact wildcards are single-level `*` only, no deep `**`; hyphenated keys need bracket notation `'req.headers["x-api-key"]'`); pino 10 serializes an Error only when it sits under the configured `errorKey` (default `err`) — `logger.error({ error: e })` skips `stdSerializers.err` and logs `{}` because Error properties are non-enumerable, silently destroying stack traces; renaming a logged field (`userId` → `user_id`) blanks every dashboard panel and alert keyed on the old name without firing anything.
|
|
81
|
+
**Safe change:** Treat logged field names as a public schema — grep `redact` config and dashboard/alert definitions before renaming; always log errors as `{ err }` (or set `errorKey` and use it consistently); after changing redact paths, assert on a captured log line that the secret is `[Redacted]`.
|
|
82
|
+
|
|
83
|
+
## express-trust-proxy-hop-count — CRITICAL · trust-invariant
|
|
84
|
+
**Contract:** `app.set('trust proxy', n)` must equal the real number of trusted proxy hops (Cloudflare, LB) so `req.ip`/`req.secure` derive from headers only as far as infrastructure you control actually sets them.
|
|
85
|
+
**Detect:** `trust proxy` in server setup, `X-Forwarded-For` / `CF-Connecting-IP` / `X-Forwarded-Proto` reads, `express-rate-limit` config, `req\.ip`, `req\.secure`
|
|
86
|
+
**Ships green, breaks:** Default (`false`) behind Cloudflare: `req.ip` is the CF edge IP for every request — rate limits collapse onto one bucket throttling all users, and IP audit logs are garbage; `trust proxy: true`: `req.ip` becomes the LEFTMOST `X-Forwarded-For` entry, which the client controls — rate-limit and IP-allowlist bypass by header injection; with trust proxy unset, `req.secure` is always false behind TLS-terminating Cloudflare, so an https-redirect middleware loops infinitely.
|
|
87
|
+
**Safe change:** Set the hop count as a number (`app.set('trust proxy', 1)` for Cloudflare directly in front; +1 per additional LB); prefer `CF-Connecting-IP` for client identity when Cloudflare is guaranteed in path; verify with a test endpoint echoing `req.ip` from a known client; re-check the number whenever a proxy layer is added or removed.
|
|
88
|
+
|
|
89
|
+
## port-and-health-rendezvous — MED · rendezvous-string
|
|
90
|
+
**Contract:** The port in `listen(PORT)`, Dockerfile `EXPOSE`, compose/K8s `ports:`, and the healthcheck URL+path all name the same endpoint from four disconnected files.
|
|
91
|
+
**Detect:** `\.listen\(`, `EXPOSE` in Dockerfiles, `ports:` / `healthcheck:` in compose, `HEALTHCHECK`, health route paths (`/health`, `/healthz`, `/ready`) in app routes vs infra config
|
|
92
|
+
**Ships green, breaks:** `EXPOSE` is pure documentation — changing the app port and updating only `EXPOSE` leaves the compose `ports:` mapping and healthcheck pointing at the old port (connection refused only in the deployed environment); a healthcheck probing `/health` when the app serves `/healthz` marks the container unhealthy → restart loop while the app itself is fine; `listen(PORT, '127.0.0.1')` inside a container is unreachable through the port mapping (must bind `0.0.0.0`/default), which works in local dev and fails only containerized.
|
|
93
|
+
**Safe change:** Single-source the port as `PORT` env consumed by app, compose, and healthcheck alike; when changing port or health path, grep Dockerfile, compose, K8s manifests, and proxy config for the old literal; never bind loopback in a container; after deploy, watch one full healthcheck interval pass green.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Pack: Cloudflare Workers platform
|
|
2
|
+
|
|
3
|
+
Load when: the touch list exhibits `wrangler.jsonc`/`wrangler.toml`, `env.X` binding reads, Durable Objects (`extends DurableObject`, `[[migrations]]`), KV/R2/D1/Queues bindings, cron triggers, service bindings, or Workers static assets.
|
|
4
|
+
|
|
5
|
+
## worker-binding-env-rendezvous — HIGH · rendezvous-string
|
|
6
|
+
**Contract:** The `binding` name strings in wrangler config (`kv_namespaces`, `r2_buckets`, `d1_databases`, `durable_objects.bindings`, `services`, `queues.producers`, `vars`) and the `env.NAME` property reads in Worker code meet on a literal identifier with no static link.
|
|
7
|
+
**Detect:** `"binding":` in `wrangler.jsonc`/`wrangler.toml`, `env\.[A-Z_]+`, `worker-configuration.d.ts`, `wrangler types`
|
|
8
|
+
**Ships green, breaks:** Renaming a binding in config (or code) compiles and deploys clean; at request time `env.OLD_NAME` is `undefined`, so the first `.get()`/`.prepare()`/`.send()` throws `TypeError: Cannot read properties of undefined` per-request — the Worker itself deploys and health-checks fine on untouched paths. A stale generated `worker-configuration.d.ts` makes TypeScript actively vouch for the dead name.
|
|
9
|
+
**Safe change:** Grep all `env.X` reads before renaming a binding; rename config and code in one commit; re-run `wrangler types` and commit the regenerated `worker-configuration.d.ts`; exercise the specific route that touches the binding, not just deploy success.
|
|
10
|
+
|
|
11
|
+
## wrangler-env-binding-non-inheritance — HIGH · config-elsewhere
|
|
12
|
+
**Contract:** Top-level bindings in wrangler config and the `env.staging`/`env.production` sub-configs are independent — bindings, `vars`, and secrets are explicitly non-inheritable, and each environment deploys a distinct Worker named `<name>-<env>`.
|
|
13
|
+
**Detect:** `"env":` block in `wrangler.jsonc`, `[env.` in `wrangler.toml`, `wrangler deploy --env`, `wrangler secret put ... --env`
|
|
14
|
+
**Ships green, breaks:** Adding a KV/D1/queue/var binding only at top level works in dev and in the default deploy, then 500s (undefined binding) only in the environment it was never copied into. Secrets are per-Worker: `wrangler secret put KEY` without `--env staging` sets it on the base Worker, and `my-worker-staging` silently has no secret. Routes must also be provided per environment.
|
|
15
|
+
**Safe change:** When adding any binding/var, add it to top level AND every `env.*` block in the same edit; run `wrangler secret put KEY --env <e>` for each environment; diff the deployed bindings per env (`wrangler deploy --env <e> --dry-run`); test each environment's URL, not just one.
|
|
16
|
+
|
|
17
|
+
## do-migrations-ledger — CRITICAL · lifecycle-protocol
|
|
18
|
+
**Contract:** Every Durable Object class rename/delete/create must be recorded as an entry in the append-only, ordered `[[migrations]]` list (`new_sqlite_classes`, `new_classes`, `renamed_classes`, `deleted_classes`, `transferred_classes`); once a Worker has a migration tag, all future deploys must carry tags.
|
|
19
|
+
**Detect:** `"migrations":` / `[[migrations]]`, `new_sqlite_classes`, `renamed_classes`, `deleted_classes`, `class_name` in `durable_objects.bindings`, `extends DurableObject`
|
|
20
|
+
**Ships green, breaks:** Renaming the exported class in code + config without a `renamed_classes` migration orphans every live object and its storage under the old class name — new requests mint fresh empty objects. `deleted_classes` deletes all Durable Objects of that class including all stored data — irreversible. The storage backend is permanent per class: you cannot enable SQLite storage on an existing deployed class — setting `new_sqlite_classes` on later migrations fails; a class born under `new_classes` (KV) is KV forever.
|
|
21
|
+
**Safe change:** Treat migrations as an append-only ledger — never edit or reorder past tags; use `new_sqlite_classes` for every new class; for renames, add `renamed_classes = [{from, to}]` in the same deploy as the code rename; never ship `deleted_classes` without confirming the data is disposable.
|
|
22
|
+
|
|
23
|
+
## do-storage-outlives-code — CRITICAL · serialized-shape
|
|
24
|
+
**Contract:** A Durable Object's `ctx.storage` key/value entries, its embedded SQLite schema (`ctx.storage.sql`), and its pending alarms persist across every deploy — new code must parse whatever old code wrote, forever.
|
|
25
|
+
**Detect:** `ctx.storage.put`, `state.storage.get`, `storage.sql.exec`, `setAlarm`, `blockConcurrencyWhile`, `async alarm(`
|
|
26
|
+
**Ships green, breaks:** Changing a stored value's shape or a `sql.exec` schema deploys green; existing objects wake up with old rows/keys and the new code misreads or crashes per-object (only the objects that have old data — staging with fresh objects looks fine). Scheduled alarms survive the deploy and fire into the new code with at-least-once semantics (retried up to 6 times, exponential backoff from 2s) — removing or renaming the `alarm()` handler while alarms are pending produces repeating runtime errors.
|
|
27
|
+
**Safe change:** Version stored payloads (a schema-version key) and migrate lazily on read, or run `storage.sql` DDL inside `blockConcurrencyWhile()` in the constructor; keep reads tolerant of the previous shape for at least one deploy; audit `setAlarm` sites before deleting/renaming `alarm()`.
|
|
28
|
+
|
|
29
|
+
## do-idfromname-key-scheme — CRITICAL · rendezvous-string
|
|
30
|
+
**Contract:** `namespace.idFromName(name)` deterministically maps a name string to one object and its storage — the string-construction scheme (e.g. `` `tenant:${id}` ``) IS the address of persisted state.
|
|
31
|
+
**Detect:** `idFromName(`, `idFromString(`, `getByName(`, `.get(id)` on a `DurableObjectNamespace`
|
|
32
|
+
**Ships green, breaks:** Any change to the name derivation (adding a prefix, switching from email to user-id, changing case/normalization) compiles fine and silently routes to brand-new empty objects; the old objects keep their data (and keep firing their alarms) but are never addressed again — state forks with no error anywhere.
|
|
33
|
+
**Safe change:** Centralize name construction in one function and treat it as frozen; if the scheme must change, ship a bridge that reads the old name's object and migrates its storage into the new one; verify against a pre-existing object, not a fresh one.
|
|
34
|
+
|
|
35
|
+
## compatibility-date-behavior-gate — HIGH · lifecycle-protocol
|
|
36
|
+
**Contract:** `compatibility_date` in wrangler config selects which default-on runtime behaviors your code runs under; bumping it is a semantic upgrade of dozens of flags, not metadata.
|
|
37
|
+
**Detect:** `compatibility_date`, `compatibility_flags`, `nodejs_compat` in `wrangler.jsonc`
|
|
38
|
+
**Ships green, breaks:** Concrete gates: ≥ `2022-10-31` switches to the spec-compliant WHATWG URL parser (`url_standard`) — URLs with odd slashes/whitespace parse differently; ≥ `2024-03-18` flips Queues `send()` default `contentType` from `v8` to `json` — structured-clone-only bodies now serialize differently; ≥ `2024-09-23` with `nodejs_compat` auto-enables `nodejs_compat_v2` — extra polyfills bundled, module resolution changes. A date bump made "to get one new API" silently drags in every other flag between the dates.
|
|
39
|
+
**Safe change:** Bump the date as its own commit with nothing else in it; read the compat-flags changelog for every flag whose default-on date falls in the interval; pin unwanted flips with explicit `no_*` flags (e.g. `no_nodejs_compat_v2`); run the full suite under `wrangler dev` (which honors the new date) before deploying.
|
|
40
|
+
|
|
41
|
+
## cron-triggers-config-drift — HIGH · config-elsewhere
|
|
42
|
+
**Contract:** `triggers.crons` in wrangler config and the `scheduled(controller, env, ctx)` handler agree on which schedules exist and (via `controller.cron` string matching) which code each fires — always evaluated in UTC.
|
|
43
|
+
**Detect:** `"triggers":`, `"crons":`, `async scheduled(`, `controller.cron ===`
|
|
44
|
+
**Ships green, breaks:** Omitting the `triggers`/`crons` key entirely does NOT remove deployed crons — currently deployed Cron Triggers are left in place, so deleted-from-config schedules keep firing zombie jobs forever; only an explicit `"crons": []` removes them. When `triggers` IS present, deploy replaces the whole set. Multiple crons dispatch to the one `scheduled` handler; dispatch is a string compare against `controller.cron` — editing the cron expression in config breaks the `===` in code with no error, the branch just never runs. All expressions are UTC; a "midnight" job drifts an hour across DST in local-time reasoning.
|
|
45
|
+
**Safe change:** Keep the `triggers` key always present (empty array when none); when editing an expression, update the matching `controller.cron` comparison in the same commit; reason about schedules in UTC only; after removing a cron, verify in the dashboard that it's gone.
|
|
46
|
+
|
|
47
|
+
## kv-eventual-consistency — HIGH · lifecycle-protocol
|
|
48
|
+
**Contract:** KV writers and readers agree the store is eventually consistent: writes (and deletes) take up to ~60 seconds to be visible in other locations, and reads are served from per-location caches.
|
|
49
|
+
**Detect:** `kv_namespaces`, `.put(` / `.get(` / `.delete(` on a KV binding, `cacheTtl`
|
|
50
|
+
**Ships green, breaks:** Read-after-write logic works in `wrangler dev` and usually within the same PoP, then intermittently reads stale or missing values in production. Negative lookups are cached too: create-key-then-read from a location that just saw it missing returns `null` for up to 60s. Passing `cacheTtl` above the 60s default extends staleness accordingly. Two concurrent writers last-write-wins with no conflict signal.
|
|
51
|
+
**Safe change:** Never gate correctness on KV read-after-write across requests; use a Durable Object or D1 for anything needing coordination or counters; treat KV as a cache of derived, idempotently-rebuildable data; if a key was read-as-missing, expect the miss to be cached ~60s.
|
|
52
|
+
|
|
53
|
+
## secrets-vars-dual-authority — HIGH · dual-authority
|
|
54
|
+
**Contract:** A Worker's plaintext `vars` are owned by wrangler config on every deploy, while secrets are owned by the out-of-band `wrangler secret put` channel (dashboard edits form a third writer) — all landing in the same `env` namespace.
|
|
55
|
+
**Detect:** `"vars":`, `keep_vars`, `wrangler secret put`, `.dev.vars`, `--secrets-file`, `"secrets":` (required-secrets validation)
|
|
56
|
+
**Ships green, breaks:** Dashboard-edited vars are silently wiped by the next `wrangler deploy` unless `keep_vars = true`. `.dev.vars` makes a never-uploaded secret work perfectly in `wrangler dev` while `env.KEY` is `undefined` in production — first noticed when auth/API calls fail (or worse, when code falls back via `env.KEY ?? devDefault`). A `vars` entry colliding with an existing secret name fails deploy with API error 10053 — loud, but only at deploy of the colliding env.
|
|
57
|
+
**Safe change:** Declare required secret names under the `secrets` config key so wrangler validates them at deploy; pick one writer for vars (config, with `keep_vars` decided explicitly); for every new `.dev.vars` entry, immediately run `wrangler secret put` per environment; never default-fallback a missing secret.
|
|
58
|
+
|
|
59
|
+
## service-binding-rpc-surface — HIGH · serialized-shape
|
|
60
|
+
**Contract:** A service binding's `service` + `entrypoint` names in the caller's config must match a separately-deployed Worker that exports that `WorkerEntrypoint` class, and every RPC method the caller invokes must exist on whatever version of the target is currently live.
|
|
61
|
+
**Detect:** `"services":` with `"service":`/`"entrypoint":` in `wrangler.jsonc`, `extends WorkerEntrypoint`, `env\.[A-Z_]+\.\w+\(` RPC calls
|
|
62
|
+
**Ships green, breaks:** The two Workers compile, typecheck, and deploy independently — renaming/removing an RPC method (or the entrypoint class) in the target deploys green, then every caller invocation throws `TypeError: The RPC receiver does not implement the method "x"` at runtime. Arguments/returns must be structured-clone-serializable (plus RPC stubs); passing a class instance throws `DataCloneError` only when the call executes. Deploy order is a live window: between target and caller deploys, one side speaks a method surface the other lacks.
|
|
63
|
+
**Safe change:** Treat the entrypoint as a public API — additive changes only, remove methods one release after all callers stop calling; share the entrypoint's type via a common package so typecheck spans the gap; deploy target-with-both-methods first, then callers, then remove; smoke-test the actual RPC call cross-worker, not each Worker alone.
|
|
64
|
+
|
|
65
|
+
## queues-body-serialization — HIGH · serialized-shape
|
|
66
|
+
**Contract:** Producer (`queues.producers` binding) and consumer (`queues.consumers` config) rendezvous on the queue name string, and the message body shape is an unversioned schema crossing between two independently-deployed Workers via serialization.
|
|
67
|
+
**Detect:** `"queues":` with `"producers":`/`"consumers":`, `.send(` / `.sendBatch(`, `contentType`, `message.body`
|
|
68
|
+
**Ships green, breaks:** Default `contentType` is `"json"` (compat date ≥ 2024-03-18; older dates default `"v8"`): `Date` silently becomes an ISO string, `Map`/`Set` become `{}`, `undefined` fields vanish — the consumer reads mangled data with no error. `"v8"`-serialized bodies are unreadable by pull-based HTTP consumers. Messages already sitting in the queue were serialized by the OLD producer — a consumer deploy expecting the new shape must still parse days-old messages (retention default 4 days, up to 14). Max message size 128 KB — a payload that grew past it fails only at `send()` time in production.
|
|
69
|
+
**Safe change:** Version the message envelope (`{v: 2, ...}`) and keep the consumer parsing v(n-1) until retention drains; deploy the tolerant consumer before the new producer; pin `contentType` explicitly; keep payloads small (IDs, not blobs).
|
|
70
|
+
|
|
71
|
+
## queues-retry-dlq-lifecycle — CRITICAL · lifecycle-protocol
|
|
72
|
+
**Contract:** Consumer batch semantics: a thrown handler fails the entire batch, each message retries up to `max_retries` (default 3), and messages exhausting retries go to the configured `dead_letter_queue` — or are permanently discarded if none is configured.
|
|
73
|
+
**Detect:** `max_retries`, `dead_letter_queue`, `max_batch_size`, `max_batch_timeout`, `message.ack()`, `message.retry(`, `batch.ackAll()`
|
|
74
|
+
**Ships green, breaks:** With no DLQ, messages that repeatedly fail are eventually discarded — one poison message plus 3 retries equals silent permanent data loss. A handler that processes messages 1–9 then throws on message 10 retries the WHOLE batch (default `max_batch_size` 10): messages 1–9 are redelivered and double-processed unless each was explicitly `ack()`ed. `ack()` is final regardless of whether the handler later throws.
|
|
75
|
+
**Safe change:** Always configure a `dead_letter_queue` (and a consumer or alert on it); `ack()` each message individually after its side effects commit; make handlers idempotent (dedupe on message id) — redelivery is at-least-once; use `retry({delaySeconds})` for backoff instead of throwing.
|
|
76
|
+
|
|
77
|
+
## workers-runtime-limits — HIGH · config-elsewhere
|
|
78
|
+
**Contract:** Code's resource consumption must fit caps declared nowhere in the code — plan tier plus optional `limits` keys in wrangler config: CPU 10 ms free / 30 s paid default (raise via `limits.cpu_ms`), 128 MB memory per isolate, subrequest caps per plan, script size 3 MB free / 10 MB paid after gzip, 6 simultaneous open connections, env vars 5 KB each.
|
|
79
|
+
**Detect:** `"limits":` / `cpu_ms` / `subrequests` in `wrangler.jsonc`, new heavy dependency in `package.json`, fan-out `Promise.all(...fetch`, large `vars` values
|
|
80
|
+
**Ships green, breaks:** All of these pass typecheck, tests, and `wrangler dev` (which doesn't enforce production caps) and fail only under production load: CPU overage kills the request mid-flight; a fan-out that grew past the subrequest cap throws "Too many subrequests"; the 6-connection cap silently queues the 7th concurrent `fetch` — un-consumed response bodies hold connections open and can deadlock a request that then awaits another fetch. A dependency bump pushing past the gzip size cap fails at deploy — but only on the free-plan project.
|
|
81
|
+
**Safe change:** After adding a dependency, check `wrangler deploy --dry-run --outdir` bundle size; consume or `cancel()` every response body; count worst-case subrequests per request path against the plan cap; load-test CPU-heavy paths on the real plan.
|
|
82
|
+
|
|
83
|
+
## d1-transaction-and-replica-consistency — HIGH · lifecycle-protocol
|
|
84
|
+
**Contract:** D1 client code and the database agree that atomicity exists ONLY via `batch()` — SQL `BEGIN TRANSACTION`/`SAVEPOINT` are rejected at runtime — and that with read replication, read-your-writes exists only inside a Sessions-API session.
|
|
85
|
+
**Detect:** `.batch(`, `.prepare(`, `.exec(`, `db.transaction(` (ORM), `withSession(`, `first-primary`, `first-unconstrained`
|
|
86
|
+
**Ships green, breaks:** ORM interactive-transaction APIs (e.g. Drizzle `db.transaction()` on D1) compile fine and fail at runtime. The tempting workaround — sequential awaited statements — is silently non-atomic: a mid-sequence failure leaves half-applied writes. `batch()` IS transactional (aborts or rolls back the entire sequence). With read replication enabled, `withSession()` defaults to `first-unconstrained` — write-then-read across separate requests can miss the write unless the bookmark is carried forward or `first-primary` is used.
|
|
87
|
+
**Safe change:** Route every multi-statement invariant through `batch()`; never emulate transactions with sequential awaits; when enabling read replication, thread session bookmarks through request flows or pin consistency-critical reads to `first-primary`.
|
|
88
|
+
|
|
89
|
+
## static-assets-shadow-worker-routes — HIGH · config-elsewhere
|
|
90
|
+
**Contract:** With `assets.directory` configured, the platform serves a matching static asset BEFORE invoking the Worker script — the asset manifest (build output) and the Worker's route handlers implicitly partition the URL space.
|
|
91
|
+
**Detect:** `"assets":` with `"directory":`, `not_found_handling`, `run_worker_first`, `"binding": "ASSETS"`
|
|
92
|
+
**Ships green, breaks:** A build-output file landing at the same path as a Worker route (e.g. a generated `/api/status` or a new `sitemap.xml` route vs an old static one) silently shadows the handler — the Worker code never runs for that path, no error anywhere. With `not_found_handling: "single-page-application"`, unmatched paths get `index.html` served as a fallback without invoking the Worker, with a 200 — so a new server-rendered or OAuth-callback route works in unit tests but browser navigations to it receive the SPA shell instead of the handler.
|
|
93
|
+
**Safe change:** List Worker-owned path prefixes in `run_worker_first: ["/api/*", "/oauth/*", ...]` and keep it updated with every new server route; check the build output directory for filename collisions with handler paths; verify new routes with a real browser navigation (not just curl of an XHR path).
|