@nurix/etna 0.18.14 → 0.18.15
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/data/harness-index.json +1 -1
- package/data/harnesses/code/harness.json +2 -2
- 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/templates/evidence.md +24 -0
- package/data/harnesses/code/skills/brainstorm/templates/rejected.md +5 -1
- package/data/harnesses/etna/harness.json +2 -2
- 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/templates/evidence.md +24 -0
- package/data/harnesses/etna/skills/brainstorm/templates/rejected.md +5 -1
- package/data/harnesses/mobile/harness.json +2 -2
- 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/templates/evidence.md +24 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/rejected.md +5 -1
- package/package.json +1 -1
- package/data/harnesses/code/skills/boundary/references/boundary-taxonomy.md +0 -796
- package/data/harnesses/etna/skills/boundary/references/boundary-taxonomy.md +0 -796
- package/data/harnesses/mobile/skills/boundary/references/boundary-taxonomy.md +0 -796
|
@@ -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).
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Pack: frontend / client state / UI build
|
|
2
|
+
|
|
3
|
+
Load when: the touch list exhibits Redux Toolkit (`createSlice`, `configureStore`), RTK Query tags, react-router route tables, Tailwind 4 CSS (`@theme`, `personality.css`, `globals.css`), Vite config (`resolve.alias`, env), `@nurix/components` imports, localforage/redux-persist, or date handling crossing the wire.
|
|
4
|
+
|
|
5
|
+
## redux-persist-shape-migration — CRITICAL · serialized-shape
|
|
6
|
+
**Contract:** State shapes frozen in the user's browser storage by redux-persist must be readable by the *current* reducers' `initialState` — `version`/`migrate` is the only bridge, and `whitelist`/`blacklist` reference reducer-map keys by literal string.
|
|
7
|
+
**Detect:** `persistReducer(`, `persistStore(`, `whitelist:`, `blacklist:`, `createMigrate(`, `version:`, `stateReconciler`, storage keys `persist:*`
|
|
8
|
+
**Ships green, breaks:** Default reconciler `autoMergeLevel1` replaces each persisted top-level slice wholesale — a field added to a persisted slice's `initialState` rehydrates as `undefined` for every returning user (fresh installs and dev with cleared storage look fine). Default `version` is `-1` and with no `migrate` supplied nothing transforms old shapes. Renaming a reducer-map key silently drops it from `whitelist` — the slice just stops persisting. The bad merged shape is re-persisted on the next write, so corruption sticks.
|
|
9
|
+
**Safe change:** Bump `version` + add a `createMigrate` step for every persisted-slice shape change; grep `whitelist`/`blacklist` when renaming reducer keys; test by rehydrating a captured copy of old storage, never a fresh state.
|
|
10
|
+
|
|
11
|
+
## vite-env-client-exposure — CRITICAL · trust-invariant
|
|
12
|
+
**Contract:** The `VITE_` name prefix (or `envPrefix`) is simultaneously the exposure gate into the public bundle and the rendezvous for `import.meta.env.VITE_X` reads — naming decides secrecy.
|
|
13
|
+
**Detect:** `import.meta.env`, `.env*`, `envPrefix`, `define:` in `vite.config.*`, grep `VITE_.*(SECRET|KEY|TOKEN|PASSWORD)` in `.env*`
|
|
14
|
+
**Ships green, breaks:** Any `VITE_`-prefixed value is string-inlined into shipped JS — `VITE_API_SECRET=…` is world-readable with zero warning (fails open). Non-prefixed vars silently read `undefined`. Env access is statically replaced at build: `import.meta.env[key]` dynamic access works in dev and returns `undefined` in production; `import.meta.env.BASE_URL` must appear verbatim (no destructuring, no aliasing to a variable) or it is never replaced.
|
|
15
|
+
**Safe change:** Audit `.env*` for secret-shaped `VITE_*` names before every release; keep server secrets unprefixed and server-side only; access env only as full literal `import.meta.env.VITE_X` expressions; verify by grepping the `dist/` output for the value.
|
|
16
|
+
|
|
17
|
+
## localforage-store-identity — CRITICAL · rendezvous-string
|
|
18
|
+
**Contract:** The `(driver, name, storeName)` triple in `localforage.config()`/`createInstance()` is the *address* of persisted user data — new code must present the identical triple to find old users' data.
|
|
19
|
+
**Detect:** `localforage.config(`, `localforage.createInstance(`, `name:`, `storeName:`, `setDriver(`, `INDEXEDDB|WEBSQL|LOCALSTORAGE`
|
|
20
|
+
**Ships green, breaks:** Changing `name` (default `"localforage"`) or `storeName` (default `"keyvaluepairs"`) opens a different IndexedDB database/object store — `getItem` resolves `null`, all prior data stranded invisibly; drivers never migrate data between each other. Extra trap: `config()` called after the first data API call **returns** (does not throw) an `Error` object — the reconfiguration silently never applies unless you check the return value. Everything stored is also a serialized-shape: returning users rehydrate last year's object shape into today's code.
|
|
21
|
+
**Safe change:** Treat `name`/`storeName` as frozen identifiers; to move, write read-old-store→write-new-store migration code; call `config()` once at module init before any data call; envelope persisted values (`{v: 2, data}`) and upconvert on read.
|
|
22
|
+
|
|
23
|
+
## redux-store-shape-selectors — HIGH · rendezvous-string
|
|
24
|
+
**Contract:** The keys of `configureStore({reducer: {…}})` ARE the state-tree paths every `useSelector(s => s.key.…)` walks, and `createSlice({name})` is the action-type prefix that string matchers dispatch on.
|
|
25
|
+
**Detect:** `configureStore(`, `combineSlices(`, `useSelector(`, `createSlice({`, `extraReducers`, `addMatcher`, action-type literals `'slice/action'`
|
|
26
|
+
**Ships green, breaks:** Renaming a reducer-map key while string-path or untyped-JS selectors still read the old key → `undefined` propagates and components render empty, no error (typed `RootState` selectors are the only statically-checked subset). Renaming slice `name` changes every dispatched action type (`user/logout` → `account/logout`) — `extraReducers` string cases, listener-middleware predicates, persistence filters, and analytics stop matching with zero feedback.
|
|
27
|
+
**Safe change:** Grep old key and old `name` across the repo including string literals; keep reducer-map key identical to slice `name`; export selectors from the slice file instead of inlining paths in components.
|
|
28
|
+
|
|
29
|
+
## rtk-query-tag-taxonomy — HIGH · rendezvous-string
|
|
30
|
+
**Contract:** `providesTags` on queries and `invalidatesTags` on mutations rendezvous on identical `{type, id}` tuples from `tagTypes` — matched by runtime string equality only.
|
|
31
|
+
**Detect:** `tagTypes:`, `providesTags`, `invalidatesTags`, `injectEndpoints`, `enhanceEndpoints({addTagTypes`
|
|
32
|
+
**Ships green, breaks:** Invalidating a tag that no query provides is a silent no-op — the mutation succeeds and the cached list stays stale until an unrelated refetch masks it. TS checks the `type` half against `tagTypes` but the `id` half is untyped: `{type:'Post', id:'LIST'}` provided vs a forgotten/misspelled `id` on the invalidate side never match. Endpoints injected via `injectEndpoints` that use tag types nobody added via `addTagTypes` never invalidate anything.
|
|
33
|
+
**Safe change:** Define the tag vocabulary once as an exported const and derive `tagTypes` from it; for every mutation, name the exact providing endpoint and diff the full tuple including `id`; verify live via Redux DevTools under `state.<api>.provided` after mutating.
|
|
34
|
+
|
|
35
|
+
## react-router-route-strings — HIGH · rendezvous-string
|
|
36
|
+
**Contract:** The route table (`createBrowserRouter` path strings and `:param` names) and every `<Link to>`, `navigate()`, `useParams().x`, `useSearchParams().get('k')` scattered across components agree on literal strings.
|
|
37
|
+
**Detect:** `createBrowserRouter(`, `path:`, `<Route path=`, `useParams(`, `useSearchParams(`, `navigate(`, `<Link to=`, `generatePath(`
|
|
38
|
+
**Ships green, breaks:** Renaming `:userId` → `:id` leaves `useParams().userId` as `undefined` — the type is already `string | undefined`, so TypeScript stays green. Moving a path 404s every hardcoded `to="/old/path"` with no build error; renaming a search-param key returns `null` silently and breaks inbound deep links (bookmarks, emails) that still carry the old key. Route typegen exists only in **framework mode** (routes.ts + the react-router Vite plugin); plain react-router-dom 7 library mode with `@vitejs/plugin-react-swc` has zero static route checking — do not assume typegen protects you.
|
|
39
|
+
**Safe change:** Centralize paths as builder functions (`routes.user(id)`) and import them for both route table and links; grep the literal path and param name before renaming; treat public search-param keys as a wire contract — support old keys during a deprecation window.
|
|
40
|
+
|
|
41
|
+
## single-react-copy-alias — HIGH · dual-authority
|
|
42
|
+
**Contract:** Exactly one React runtime may own the hooks dispatcher and context registry — `@nurix/*` packages ship a nested `react`, so the app's `vite.config` `resolve.alias` must pin `react`, `react-dom`, `react/jsx-runtime`, and `react/jsx-dev-runtime` to the app's single copy.
|
|
43
|
+
**Detect:** `resolve.alias` in `vite.config.*`, `node_modules/@nurix/*/node_modules/react`, `resolve.dedupe`, `npm ls react`
|
|
44
|
+
**Ships green, breaks:** Two copies bundle with no warning; symptoms are runtime-only: "Invalid hook call", `TypeError: Cannot read properties of null (reading 'useState')`, and the fully silent one — `useContext` inside a library component resolves against the other copy's context object and returns the **default value**; react-redux 9 surfaces this as "could not find react-redux context value" even though the Provider is right there. Dev (esbuild prebundle) and build (Rollup) can dedupe differently, so it can pass `vite dev` and break `vite build`, or the reverse.
|
|
45
|
+
**Safe change:** Alias all four specifiers — the automatic JSX transform imports `react/jsx-runtime`/`jsx-dev-runtime` directly, so aliasing bare `react` alone is insufficient; after any `@nurix/*` or React upgrade, run `vite build && vite preview` and exercise a library component that consumes app context; confirm a single copy with `npm ls react`.
|
|
46
|
+
|
|
47
|
+
## tailwind-theme-token-rename — HIGH · rendezvous-string
|
|
48
|
+
**Contract:** Each `@theme` variable is simultaneously (a) a generated utility family (`--color-brand` → `bg-brand`/`text-brand`/`border-brand`) and (b) a plain CSS variable read via `var(--color-brand)` — one identifier, two independent consumer populations.
|
|
49
|
+
**Detect:** `@theme` blocks in `globals.css`/`personality.css`, `var(--color-`, `var(--radius`, grep utility classes built from the token stem
|
|
50
|
+
**Ships green, breaks:** Renaming a token compiles clean; every class using the old utility silently vanishes from the generated stylesheet (Tailwind emits nothing for unknown classes — elements just lose styling), and every `var(--old-name)` reader (handwritten CSS, inline styles, the component library) falls back to `initial`. Namespaces gate generation: only recognized namespaces (`--color-*`, `--font-*`, `--spacing-*`, `--radius-*`, `--shadow-*`, …) produce utilities — a token moved to an unrecognized namespace keeps the variable but kills the utility with no diagnostic.
|
|
51
|
+
**Safe change:** Grep both consumer forms — utility stems (`bg-old`, `text-old`) and `var(--…-old)` including `node_modules/@nurix/components` dist CSS; keep the old token as an alias for one release; visually diff themed screens after the rename.
|
|
52
|
+
|
|
53
|
+
## tailwind-theme-inline-indirection — HIGH · config-elsewhere
|
|
54
|
+
**Contract:** `@theme inline { --color-primary: var(--primary) }` makes utilities emit the *referenced* variable (`background-color: var(--primary)`), resolved per-element at runtime — the org's theming (personality.css vars swapped under `[data-theme]`/`[data-style]`) only flows through utilities via the `inline` form.
|
|
55
|
+
**Detect:** `@theme inline`, plain `@theme {`, token values that are `var(--…)`, `[data-theme=`/`[data-style=` blocks in `personality.css`
|
|
56
|
+
**Ships green, breaks:** Adding a var-referencing token to a plain (non-`inline`) `@theme` block compiles and looks correct in the default theme; switching `data-theme`/`data-style` then silently fails to restyle those utilities, because the theme variable captured the referenced var's value as resolved at `:root` — subtree/attribute-scoped overrides never reach it. No warning, no error; only the alternate theme is wrong.
|
|
57
|
+
**Safe change:** Any token whose value is `var(--app-var)` goes in the `@theme inline` block, raw literal values in plain `@theme`; after adding tokens, toggle `data-theme` and `data-style` in devtools and confirm the new utilities respond in both states.
|
|
58
|
+
|
|
59
|
+
## tailwind-source-detection — HIGH · generated-artifact
|
|
60
|
+
**Contract:** The generated stylesheet contains only class names that appear as complete, unbroken literal strings in scanned source files — the build-time scanner vs class names assembled at runtime; the default scan set skips `.gitignore`d paths and `node_modules`.
|
|
61
|
+
**Detect:** `` `…${ `` inside `className`, `clsx(`/`cn(` with computed strings, class names arriving from API/CMS/config/DB, `@source` directives in CSS
|
|
62
|
+
**Ships green, breaks:** `bg-${color}-500` builds and renders unstyled — the utility is simply absent from the CSS. Class names stored in data or composed in unscanned code work only as long as some scanned file coincidentally contains the same literal — deleting that last literal usage breaks a "different" feature. Gitignored source directories are silently excluded from scanning.
|
|
63
|
+
**Safe change:** Always write full class literals and select via lookup maps (`{primary: 'bg-blue-600'}[variant]`); safelist genuinely dynamic sets with `@source inline("…")` (v4.1+); add explicit `@source "…"` for out-of-tree or ignored directories.
|
|
64
|
+
|
|
65
|
+
## dark-variant-data-attributes — HIGH · rendezvous-string
|
|
66
|
+
**Contract:** `@custom-variant dark (&:where([data-theme=dark] *))` and the personality selectors `[data-theme="…"]`/`[data-style="…"]` rendezvous on the exact attribute name+value that runtime JS stamps on `<html>` (`document.documentElement.dataset.theme/style`).
|
|
67
|
+
**Detect:** `@custom-variant dark`, `[data-theme=`, `[data-style=`, `dataset.theme`, `setAttribute('data-theme'`, `classList.add('dark')`
|
|
68
|
+
**Ships green, breaks:** Without the `@custom-variant dark` override, Tailwind's default `dark:` variant is the `prefers-color-scheme: dark` media query — every `dark:` class silently follows the OS setting and ignores the app's toggle entirely. A value or mechanism mismatch (JS writes `class="dark"` while CSS expects `[data-theme=dark]`, or a casing typo) leaves the alternate theme unreachable with zero errors. Org default is light: base tokens must stay scoped `:root, :root[data-theme="light"]` or unstamped pages render the wrong theme.
|
|
69
|
+
**Safe change:** Define attribute name and values as one exported constant consumed by the toggle code; add `@custom-variant dark` the moment any `dark:` utility pairs with an attribute toggle; flip the attribute in devtools and verify both directions plus the no-attribute default.
|
|
70
|
+
|
|
71
|
+
## nurix-components-styles-tokens — HIGH · rendezvous-string
|
|
72
|
+
**Contract:** `@nurix/components` is imported by subpath (`@nurix/components/button`) but styled through two separate channels — its own pre-built `styles.css` (imported in `globals.css`) and the app's semantic CSS variables (personality tokens like `--radius-*`, `--color-*`) that its rules read.
|
|
73
|
+
**Detect:** `from '@nurix/components/`, the library `styles.css` `@import` line in `globals.css`, grep `var(--` in `node_modules/@nurix/components/**/styles.css`
|
|
74
|
+
**Ships green, breaks:** Omitting the `styles.css` import renders every component fully functional but unstyled — no JS error, no build warning, no missing-module failure. Renaming or dropping a personality token the library reads silently degrades components to fallback/`initial` values (e.g. a flat archetype's `--radius-*: 0` re-assertion lost in a token refactor). The library is pre-built, so the app's Tailwind scanner never generates utilities for markup inside it — its shipped CSS is the only style source.
|
|
75
|
+
**Safe change:** Keep the `styles.css` import at its mandated position in `globals.css`; before touching any personality token, grep the library's dist CSS for `var(--that-token)`; smoke-render a themed library component after every `@nurix/components` upgrade.
|
|
76
|
+
|
|
77
|
+
## vite-alias-tsconfig-paths — MED · dual-authority
|
|
78
|
+
**Contract:** Path specifiers like `@/lib/x` are resolved by two independent authorities — `tsconfig.json` `compilerOptions.paths` for typecheck/editor and `vite.config` `resolve.alias` for the served bundle.
|
|
79
|
+
**Detect:** `"paths":` in `tsconfig*.json`, `resolve.alias` in `vite.config.*`, `vite-tsconfig-paths` in `package.json`
|
|
80
|
+
**Ships green, breaks:** A missing Vite alias fails loud; the silent case is *divergent targets* — after a folder move or a one-sided edit, `tsc --noEmit` resolves one file while the bundle executes another, and same-named exports mask it completely. Editor auto-imports follow tsconfig and keep generating specifiers Vite resolves elsewhere.
|
|
81
|
+
**Safe change:** Single-source the mapping (`vite-tsconfig-paths`, or generate both from one constant); update both files in the same commit on any directory move; when suspicious, log `import.meta.url` from the module in dev to see which file actually loaded.
|
|
82
|
+
|
|
83
|
+
## css-import-order — MED · lifecycle-protocol
|
|
84
|
+
**Contract:** `globals.css` must import in the fixed sequence `@import "./personality.css"` → `@import "tailwindcss"` → the library's `styles.css` → `@theme inline {…}` — cascade-layer order is fixed at first appearance of each `@layer` name, so file position *is* the styling contract between Tailwind, the component library, and the personality tokens.
|
|
85
|
+
**Detect:** first ~10 lines of `globals.css`, `@import "tailwindcss"`, `@import` of `@nurix/components` styles, `@layer` declarations inside the library CSS
|
|
86
|
+
**Ships green, breaks:** Any reorder compiles and mostly renders — the breakage is visual-only and theme/element-specific: importing the library's `styles.css` before `@import "tailwindcss"` lets preflight's `@layer base` resets land after (and wipe) library button/form styling at equal specificity, and flips which side wins app-utility-vs-library override wars. Nothing logs; a form-heavy screen just looks subtly wrong.
|
|
87
|
+
**Safe change:** Treat the four-line header as frozen; insert new global stylesheets at a deliberate position relative to `tailwindcss` and the library import, never appended blindly; after any reorder, visual-diff a form-heavy screen in every `data-theme`/`data-style` combination.
|
|
88
|
+
|
|
89
|
+
## date-fns-local-tz — HIGH · serialized-shape
|
|
90
|
+
**Contract:** Datetimes cross the wire as UTC ISO strings or date-only `YYYY-MM-DD`, but date-fns `format()` and accessors operate in the runtime's local timezone — serializer and formatter must agree on the zone, and nothing checks that they do.
|
|
91
|
+
**Detect:** `format(`, `parseISO(`, `new Date('20` with date-only literals, `@date-fns/tz`, `TZDate`, `{ in:` option
|
|
92
|
+
**Ships green, breaks:** `new Date("2026-07-03")` parses as UTC midnight; `format(…, 'yyyy-MM-dd')` in any negative-offset zone renders `2026-07-02` — dates shift a day only for users west of UTC, while CI (UTC) and the developer's machine may pass. Writing the formatted local date back corrupts stored data. date-fns 4's fix is opt-in, not default: pass `{ in: tz('UTC') }` or use `TZDate` from `@date-fns/tz` — plain `format` remains local-zone forever.
|
|
93
|
+
**Safe change:** Classify each field: calendar dates stay strings end-to-end (never round-trip through `Date`); instants stay UTC ISO and format with an explicit `in:`/`TZDate`; run tests under a non-UTC zone (`TZ=America/New_York`) to surface the shift.
|