@ciromaciel/contract 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @ciromaciel/contract
2
+
3
+ O contrato de API da stack RiLiGar: um envelope, um catálogo de códigos de erro,
4
+ e os helpers que tornam impossível responder fora dele.
5
+
6
+ A norma escrita vive [no
7
+ infrastructure](https://github.com/riligar/infrastructure/blob/prod/apps/website/docs/2026-09-09-api-contract.md).
8
+ Este pacote é a norma **executável**: o que os produtos importam para responder,
9
+ e o que os testes importam para conferir.
10
+
11
+ ```bash
12
+ bun add @ciromaciel/contract
13
+ ```
14
+
15
+ ```js
16
+ import { ok, created, failure, collection } from '@ciromaciel/contract'
17
+ import { CODES } from '@ciromaciel/contract/codes'
18
+
19
+ return ok({ id, nome })
20
+ return failure(CODES.NOT_FOUND, 'projeto não encontrado')
21
+ ```
22
+
23
+ ## Os schemas ficam num caminho próprio
24
+
25
+ ```js
26
+ import { envelope } from '@ciromaciel/contract/schemas' // requer zod
27
+ ```
28
+
29
+ `schemas.js` importa Zod, que é um peer **opcional** — um worker que só precisa
30
+ responder não deveria carregar um validador de 60 kB. Enquanto o índice
31
+ reexportava os schemas, o import era eager: pedir `failure` puxava Zod junto, e
32
+ a instalação quebrava com *"Cannot find package 'zod'"* para quem tratou o peer
33
+ como opcional. Quem quer os schemas importa o caminho deles, onde Zod é um
34
+ requisito legítimo e declarado.
35
+
36
+ ## A regra que este pacote torna mecânica
37
+
38
+ Um agente que aprendeu um produto da RiLiGar já sabe usar os outros seis.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@ciromaciel/contract",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "The API contract of the RiLiGar stack: a single envelope, a catalogue of error codes, and the helpers that make it impossible to answer outside it.",
7
+ "main": "src/index.js",
8
+ "files": [
9
+ "src",
10
+ "README.md"
11
+ ],
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./codes": "./src/codes.js",
15
+ "./schemas": "./src/schemas.js",
16
+ "./plan": "./src/plan/index.js"
17
+ },
18
+ "scripts": {
19
+ "test": "bun test tests/"
20
+ },
21
+ "peerDependencies": {
22
+ "zod": "^4.0.0"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "zod": {
26
+ "optional": true
27
+ }
28
+ },
29
+ "devDependencies": {
30
+ "zod": "^4.5.4"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/riligar/packages.git",
38
+ "directory": "contract"
39
+ }
40
+ }
package/src/codes.js ADDED
@@ -0,0 +1,227 @@
1
+ // ============================================================================
2
+ // ERROR CODES — the stack's closed vocabulary
3
+ // ============================================================================
4
+ //
5
+ // `code` is the part of the error the AGENT reads. `message` is for the human
6
+ // and its wording may change at any time; `code` may not — renaming a code is
7
+ // a breaking change to the contract, and that is why it lives here, in a closed
8
+ // enum, instead of loose in each handler.
9
+ //
10
+ // This vocabulary was not invented: it is the consolidation of the `reason`
11
+ // values that already existed scattered across the seven products
12
+ // (`plan_limit`, `has_resources`, `taken`, `expired`, `duplicate_name`,
13
+ // `no_access`…). The stack had already converged on its own — it just had never
14
+ // written it down, and so each product spelled the same concept its own way.
15
+ //
16
+ // Adding a code is a deliberate change: it goes here, not in the handler.
17
+
18
+ export const CODES = {
19
+ // ── Identity and access ──────────────────────────────────────────────
20
+ /** No credential, or an unreadable one. The client did not identify itself. */
21
+ UNAUTHORIZED: 'UNAUTHORIZED',
22
+ /** Identified, but without permission for THIS resource. */
23
+ FORBIDDEN: 'FORBIDDEN',
24
+ /** Valid token, issued for ANOTHER server (RFC 8707). */
25
+ WRONG_AUDIENCE: 'WRONG_AUDIENCE',
26
+ /** The credential does not cover the operation — missing scope, not permission. */
27
+ INSUFFICIENT_SCOPE: 'INSUFFICIENT_SCOPE',
28
+
29
+ // ── The resource ─────────────────────────────────────────────────────
30
+ /** Does not exist, or is not visible to whoever is asking. */
31
+ NOT_FOUND: 'NOT_FOUND',
32
+ /** Another one already exists with the same natural key (name, slug, e-mail). */
33
+ CONFLICT: 'CONFLICT',
34
+ /** The requested name is taken. It is a CONFLICT with its own diagnosis. */
35
+ NAME_TAKEN: 'NAME_TAKEN',
36
+ /** It existed and no longer holds — deploy version, token, invite link. */
37
+ EXPIRED: 'EXPIRED',
38
+ /** The removal runs into dependents that have to go first. */
39
+ HAS_RESOURCES: 'HAS_RESOURCES',
40
+
41
+ // ── The request ──────────────────────────────────────────────────────
42
+ /** Malformed body, query or path. `details` carries what failed. */
43
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
44
+ /** Syntactically valid, but impossible in the resource's current state. */
45
+ UNPROCESSABLE: 'UNPROCESSABLE',
46
+ /** Exceeded the call window. `details.retryAfter` says when to come back. */
47
+ RATE_LIMITED: 'RATE_LIMITED',
48
+ /** Body larger than the accepted ceiling. */
49
+ PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
50
+
51
+ // ── Commercial ───────────────────────────────────────────────────────
52
+ /** The current plan does not cover it. `details` brings current/limit/requiredPlan. */
53
+ PLAN_LIMIT: 'PLAN_LIMIT',
54
+ /** The owner's subscription is not active. Blocks the whole operation. */
55
+ SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',
56
+
57
+ // ── Us ───────────────────────────────────────────────────────────────
58
+ /** Unexpected failure. NEVER carries an internal exception message. */
59
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
60
+ /** External dependency down. It is transient — worth retrying. */
61
+ SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
62
+ /** A third-party provider refused (gateway, SES, Cloudflare). */
63
+ UPSTREAM_ERROR: 'UPSTREAM_ERROR',
64
+ }
65
+
66
+ /**
67
+ * The HTTP status of each code.
68
+ *
69
+ * The table lives here so that status and code NEVER disagree: a `NOT_FOUND`
70
+ * with a 200 is the defect this migration exists to eliminate, and the only way
71
+ * to guarantee that is for the caller not to pick the number.
72
+ */
73
+ export const STATUS_BY_CODE = {
74
+ UNAUTHORIZED: 401,
75
+ FORBIDDEN: 403,
76
+ WRONG_AUDIENCE: 401,
77
+ INSUFFICIENT_SCOPE: 403,
78
+
79
+ NOT_FOUND: 404,
80
+ CONFLICT: 409,
81
+ NAME_TAKEN: 409,
82
+ EXPIRED: 410,
83
+ HAS_RESOURCES: 409,
84
+
85
+ VALIDATION_ERROR: 400,
86
+ UNPROCESSABLE: 422,
87
+ RATE_LIMITED: 429,
88
+ PAYLOAD_TOO_LARGE: 413,
89
+
90
+ PLAN_LIMIT: 402,
91
+ SUBSCRIPTION_REQUIRED: 402,
92
+
93
+ INTERNAL_ERROR: 500,
94
+ SERVICE_UNAVAILABLE: 503,
95
+ UPSTREAM_ERROR: 502,
96
+ }
97
+
98
+ /** A transient error deserves a retry; the others do not. */
99
+ export const RETRIABLE = new Set([CODES.RATE_LIMITED, CODES.SERVICE_UNAVAILABLE, CODES.UPSTREAM_ERROR])
100
+
101
+ /**
102
+ * The old `reason` values, and the code each one became.
103
+ *
104
+ * WHY THIS EXISTS, AND WHY IT IS NOT DEBT
105
+ *
106
+ * Before this package, each product had its own vocabulary of `reason` values
107
+ * in snake_case: `invalid_request`, `plan_limit`, `not_found`. There are
108
+ * hundreds of calls scattered around — in Auth alone there are 54. Swapping
109
+ * them all at once would be a sweeping refactor, with real risk and zero
110
+ * visible gain: what the API client reads is the `code` of the RESPONSE, and
111
+ * that already comes out correct.
112
+ *
113
+ * The translation happens at the edge, once, here. `failure('invalid_request')`
114
+ * emits `VALIDATION_ERROR` — the handler stays readable in the language it
115
+ * already spoke, and the response honours the contract.
116
+ *
117
+ * WHAT THIS IS NOT
118
+ *
119
+ * It is not a second vocabulary. Nothing here is a valid output code: the map
120
+ * only translates INTO `CODES`, never out of it. New code writes
121
+ * `CODES.VALIDATION_ERROR` directly; this table only covers what already
122
+ * existed.
123
+ *
124
+ * The map is closed on purpose. A `reason` that is not here falls into
125
+ * `INTERNAL_ERROR` — loud, and that is the right call: an unknown code is a bug
126
+ * in whoever called, not something to guess at.
127
+ */
128
+ export const ALIASES = {
129
+ // ── Validation ───────────────────────────────────────────────────────
130
+ validation_error: CODES.VALIDATION_ERROR,
131
+ invalid_request: CODES.VALIDATION_ERROR,
132
+ invalid_email: CODES.VALIDATION_ERROR,
133
+ bad_request: CODES.VALIDATION_ERROR,
134
+ missing_field: CODES.VALIDATION_ERROR,
135
+
136
+ // ── Identity and access ──────────────────────────────────────────────
137
+ unauthorized: CODES.UNAUTHORIZED,
138
+ forbidden: CODES.FORBIDDEN,
139
+ access_denied: CODES.FORBIDDEN,
140
+ no_access: CODES.FORBIDDEN,
141
+ application_mismatch: CODES.FORBIDDEN,
142
+ wrong_audience: CODES.WRONG_AUDIENCE,
143
+ insufficient_scope: CODES.INSUFFICIENT_SCOPE,
144
+
145
+ // ── Resource ─────────────────────────────────────────────────────────
146
+ not_found: CODES.NOT_FOUND,
147
+ conflict: CODES.CONFLICT,
148
+ taken: CODES.NAME_TAKEN,
149
+ name_taken: CODES.NAME_TAKEN,
150
+ duplicate_name: CODES.NAME_TAKEN,
151
+ expired: CODES.EXPIRED,
152
+ expired_token: CODES.EXPIRED,
153
+ has_resources: CODES.HAS_RESOURCES,
154
+ organization_not_empty: CODES.HAS_RESOURCES,
155
+
156
+ // ── Unprocessable ────────────────────────────────────────────────────
157
+ unprocessable: CODES.UNPROCESSABLE,
158
+ no_op: CODES.UNPROCESSABLE,
159
+ last_owner: CODES.UNPROCESSABLE,
160
+ // RFC 8628: the device flow waits for the human to type the code. It is
161
+ // neither a client error nor a server failure — it is "not yet".
162
+ authorization_pending: CODES.UNPROCESSABLE,
163
+
164
+ // ── Plan and billing ─────────────────────────────────────────────────
165
+ plan_limit: CODES.PLAN_LIMIT,
166
+ subscription_required: CODES.SUBSCRIPTION_REQUIRED,
167
+
168
+ // ── Pace and size ────────────────────────────────────────────────────
169
+ rate_limited: CODES.RATE_LIMITED,
170
+ slow_down: CODES.RATE_LIMITED,
171
+ payload_too_large: CODES.PAYLOAD_TOO_LARGE,
172
+
173
+ // ── Our failure, or a third party's ──────────────────────────────────
174
+ server_error: CODES.INTERNAL_ERROR,
175
+ internal_error: CODES.INTERNAL_ERROR,
176
+ service_unavailable: CODES.SERVICE_UNAVAILABLE,
177
+ upstream_error: CODES.UPSTREAM_ERROR,
178
+ email_delivery_failed: CODES.UPSTREAM_ERROR,
179
+ }
180
+
181
+ export const isCode = value => Object.prototype.hasOwnProperty.call(STATUS_BY_CODE, value)
182
+
183
+ /**
184
+ * The canonical code, derived from whatever the handler wrote.
185
+ *
186
+ * Accepts the already canonical code (passes straight through), an old `reason`
187
+ * (translates it), or anything else (`INTERNAL_ERROR`). It is the only point in
188
+ * the stack that decides this — before, each product had its own table, and they
189
+ * diverged.
190
+ */
191
+ export const toCode = value => (isCode(value) ? value : ALIASES[value] || CODES.INTERNAL_ERROR)
192
+
193
+ /**
194
+ * The code, derived from an HTTP status.
195
+ *
196
+ * For the places that already had the number at hand — a `throw { status: 401 }`
197
+ * caught in a catch, for example — and have nowhere to get the code from.
198
+ */
199
+ export const STATUS_TO_CODE = {
200
+ 400: CODES.VALIDATION_ERROR,
201
+ 401: CODES.UNAUTHORIZED,
202
+ 402: CODES.PLAN_LIMIT,
203
+ 403: CODES.FORBIDDEN,
204
+ 404: CODES.NOT_FOUND,
205
+ /*
206
+ * 405 is VALIDATION_ERROR: the route exists, the METHOD does not belong to
207
+ * it — the client sent something malformed, not the server failing.
208
+ *
209
+ * It was missing here while Functions and Monitors each carried it in their
210
+ * own copy of the table, which is the shape this omission takes: whoever
211
+ * copies adds what they need, and the package that is supposed to be the
212
+ * norm ends up the poorest of the four. Without this line their `405`s
213
+ * would fall into `INTERNAL_ERROR` and announce a server failure for what
214
+ * is a caller mistake.
215
+ */
216
+ 405: CODES.VALIDATION_ERROR,
217
+ 409: CODES.CONFLICT,
218
+ 410: CODES.EXPIRED,
219
+ 413: CODES.PAYLOAD_TOO_LARGE,
220
+ 422: CODES.UNPROCESSABLE,
221
+ 429: CODES.RATE_LIMITED,
222
+ 500: CODES.INTERNAL_ERROR,
223
+ 502: CODES.UPSTREAM_ERROR,
224
+ 503: CODES.SERVICE_UNAVAILABLE,
225
+ }
226
+
227
+ export const codeFromStatus = status => STATUS_TO_CODE[status] || CODES.INTERNAL_ERROR
package/src/index.js ADDED
@@ -0,0 +1,25 @@
1
+ // ============================================================================
2
+ // @ciromaciel/contract — the API contract of the seven products
3
+ // ============================================================================
4
+ //
5
+ // The written norm lives in https://github.com/riligar/infrastructure/blob/prod/apps/website/docs/2026-09-09-api-contract.md. This
6
+ // package is the EXECUTABLE norm: what the products import in order to respond,
7
+ // and what the tests import in order to check.
8
+ //
9
+ // The golden rule is the one this package makes mechanical: **an agent that has
10
+ // learned one RiLiGar product already knows how to use the other six.**
11
+ //
12
+ // WHY THE SCHEMAS DO NOT COME OUT OF HERE
13
+ //
14
+ // `schemas.js` imports Zod, which is an OPTIONAL peer — a worker that only needs
15
+ // to respond should not have to load a 60 kB validator. But as long as this file
16
+ // reexported the schemas, the import was eager: `import { failure } from
17
+ // '@ciromaciel/contract'` pulled in `schemas.js`, which pulled in Zod, and the
18
+ // install broke with "Cannot find package 'zod'" for anyone who treated the peer
19
+ // as optional. The optional peer was not optional.
20
+ //
21
+ // Whoever wants the schemas imports `@ciromaciel/contract/schemas`, and there Zod
22
+ // is a legitimate requirement of that path — declared in the export map, not
23
+ // hidden in a chain of reexports.
24
+ export { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus, ALIASES } from './codes.js'
25
+ export { ok, created, collection, failure, failureWithStatus, failureBody, pagination, page } from './responses.js'
@@ -0,0 +1,365 @@
1
+ // ============================================================================
2
+ // THE PLAN GATE — one implementation for the seven products
3
+ // ============================================================================
4
+ //
5
+ // Seven copies of this file existed, one per product, and they had ALREADY
6
+ // diverged in ways that cost money in production. None of them raised an error:
7
+ // a billing gate fails by returning the WRONG NUMBER, and the wrong number
8
+ // looks exactly like the right one.
9
+ //
10
+ // - Six granted every product to whoever subscribed to any ONE of them,
11
+ // because `hasActiveSubscription` is global to the stack. Measured: one
12
+ // Hoster subscription running 27 monitors.
13
+ // - One read the answer under a `data` envelope that no longer existed, so
14
+ // every subscriber silently fell to the floor.
15
+ // - One read the limit from `plan_features.value`, NULL across the whole
16
+ // catalog, pinning a Scale subscriber to one project.
17
+ // - One announced a limit in its MCP that its own gate then refused.
18
+ //
19
+ // The last one is the shape of all of them: TWO READERS OF THE SAME RULE, one
20
+ // of which drifted. That is what this package removes — not the lines, the
21
+ // second reader.
22
+ //
23
+ // WHY THIS IS SHARED WHEN `gate-telemetry.js` DELIBERATELY WAS NOT: the
24
+ // telemetry is counting nobody gets wrong, and sharing it would tie five
25
+ // release cycles together to save forty lines. This is the reasoning that took
26
+ // three production incidents to get right.
27
+ //
28
+ // IT RUNS ON BOTH RUNTIMES — four Workers, three Node/Bun managers. Only
29
+ // `fetch`, `AbortSignal.timeout`, `Map` and `JSON`, with no binding of either.
30
+ //
31
+ // The full rule, including when a product should freeze rather than warn, is in
32
+ // docs/2026-09-16-gate-de-plano.md.
33
+
34
+ import { recordGateDecision } from './telemetry.js'
35
+
36
+ const PERMISSIONS_TIMEOUT_MS = 3000
37
+
38
+ /**
39
+ * How long a KNOWN answer is reused.
40
+ *
41
+ * The two costs are not symmetric. Too long, and somebody who just paid keeps
42
+ * being told to pay — the moment they are most likely to ask for a refund. Too
43
+ * short, and we make more calls to Payments, which is cheap and bounded. Sixty
44
+ * seconds keeps the post-checkout wait under a minute, and `invalidate()` makes
45
+ * the good case immediate anyway.
46
+ */
47
+ const CACHE_TTL_MS = 60_000
48
+
49
+ /** A ceiling, not a working set: past it the map is dropped, not evicted. */
50
+ const CACHE_MAX_ENTRIES = 1000
51
+
52
+ /**
53
+ * Builds the plan gate for one product.
54
+ *
55
+ * @param {object} options
56
+ * @param {string} options.product Slug prefix proving THIS product was paid
57
+ * for: `auth`, `monitors`, …
58
+ * @param {string} options.resource What is counted, for the error payload.
59
+ * @param {Array<{slug: string, limit: number}>} options.tiers
60
+ * Quantity slugs, any order — the LARGEST
61
+ * match wins.
62
+ * @param {number} [options.free=1] Floor for whoever has no plan of this
63
+ * product. Hoster passes 0: there, a
64
+ * project cannot publish without a
65
+ * subscription, so a free one would be a
66
+ * project that does nothing.
67
+ * @param {string} [options.freePlanName]
68
+ * Name of the free tier when the catalog
69
+ * actually has one — Payments' `Base`.
70
+ * Null everywhere else, which is what makes
71
+ * `canCreate` answer SUBSCRIPTION_REQUIRED
72
+ * instead of PLAN_LIMIT.
73
+ */
74
+ export function createPlanGate({ product, resource, tiers, free = 1, freePlanName = null } = {}) {
75
+ if (!product) throw new Error('createPlanGate: `product` is required — it is the slug prefix that proves this product was paid for')
76
+ if (!resource) throw new Error('createPlanGate: `resource` is required — it names what is counted in the error payload')
77
+ if (!Array.isArray(tiers) || tiers.length === 0) throw new Error('createPlanGate: `tiers` must list at least one quantity slug')
78
+
79
+ /**
80
+ * A KNOWN answer: Payments replied, and there is no plan of this product.
81
+ *
82
+ * The floor is `free`, and for six products that is 1 rather than 0 —
83
+ * enough to make the product real to whoever is evaluating it, and a
84
+ * product that cannot be tried cannot be bought.
85
+ */
86
+ const noPlan = () => ({ plan: freePlanName, limit: free, subscribed: false, unknown: false, slugs: new Set() })
87
+
88
+ /**
89
+ * We could not ask — Payments down, erroring, or the secret is missing.
90
+ *
91
+ * `subscribed: true` does NOT assert that the person subscribed: it asserts
92
+ * that this gate has no basis on which to deny. `unknown: true` is what
93
+ * separates "could not ask" from "asked, and there is no limit" — both
94
+ * carry Infinity and both release, and only the first is a problem. Without
95
+ * the flag, every legitimate unlimited plan would count as blindness.
96
+ */
97
+ const unknown = () => ({ plan: null, limit: Infinity, subscribed: true, unknown: true, slugs: null })
98
+
99
+ /** ownerId -> { entitlements, expiresAt }. Only KNOWN answers land here. */
100
+ const cache = new Map()
101
+
102
+ function readCache(ownerId) {
103
+ const hit = cache.get(ownerId)
104
+ if (!hit) return null
105
+ if (hit.expiresAt <= Date.now()) {
106
+ cache.delete(ownerId)
107
+ return null
108
+ }
109
+ return hit.entitlements
110
+ }
111
+
112
+ function writeCache(ownerId, entitlements) {
113
+ /*
114
+ * NEVER remember a blind decision: it would turn one timeout into a
115
+ * full minute of blind releases, and would under-count the blind rate —
116
+ * the monitor would see one where there were hundreds.
117
+ */
118
+ if (entitlements.unknown) return
119
+ if (cache.size >= CACHE_MAX_ENTRIES) cache.clear()
120
+ cache.set(ownerId, { entitlements, expiresAt: Date.now() + CACHE_TTL_MS })
121
+ }
122
+
123
+ async function resolve(cfg, ownerId) {
124
+ if (!ownerId) return noPlan()
125
+
126
+ const secretKey = cfg?.PAYMENTS_SECRET_KEY
127
+ const base = cfg?.PAYMENTS_API_URL
128
+
129
+ /*
130
+ * SERVER TO SERVER, with the secret key. The public key lives in the
131
+ * dashboard bundle and answers for any userId a visitor happens to
132
+ * know, which makes it enumerable and useless for authorization.
133
+ *
134
+ * Unconfigured, the gate is OFF rather than closed: that is the state
135
+ * of a deploy that has not yet received its secret, and locking
136
+ * everyone out during that window is worse than having no gate.
137
+ */
138
+ if (!secretKey || !base) return unknown()
139
+
140
+ try {
141
+ const res = await fetch(`${base}/sdk/v1/permissions?userId=${encodeURIComponent(ownerId)}`, {
142
+ headers: { Authorization: `Bearer ${secretKey}` },
143
+ signal: AbortSignal.timeout(PERMISSIONS_TIMEOUT_MS),
144
+ })
145
+
146
+ if (!res.ok) {
147
+ console.error(`[plan] Payments answered ${res.status} for ${ownerId} — allowing`)
148
+ return unknown()
149
+ }
150
+
151
+ /*
152
+ * THE RESOURCE IS AT THE ROOT — no fallback to `body.data`. The old
153
+ * envelope is gone everywhere, and accepting it would only hide a
154
+ * Payments that did not migrate, which is exactly the case where
155
+ * the read goes wrong in silence.
156
+ */
157
+ const data = (await res.json()) ?? {}
158
+
159
+ if (!data.hasActiveSubscription) return noPlan()
160
+
161
+ const slugs = new Set((data.features || []).map(f => f.slug))
162
+ const matched = tiers.filter(t => slugs.has(t.slug))
163
+
164
+ /*
165
+ * O PLANO DESTE PRODUTO, não o primeiro da lista.
166
+ *
167
+ * `subscriptions[0]` foi o que os sete produtos fizeram desde
168
+ * sempre, e é arbitrário: quem assina dois vê o nome de um deles
169
+ * por ordem de chegada. Medido em produção em 2026-09-16 — uma
170
+ * conta com Hoster Scale e Monitors Scale recebia
171
+ * `plan: "Hoster — Scale"` do `monitors_get_account`.
172
+ *
173
+ * O dano não é cosmético. A recusa monta a frase a partir daqui, e
174
+ * ela sairia como "O plano Hoster — Scale inclui 5 monitores" — um
175
+ * limite que aquele plano não define, sobre um produto que ele não
176
+ * cobre. Quem lê não tem como saber que a frase está errada.
177
+ *
178
+ * O nome do plano deste produto começa com o nome do produto
179
+ * ("Monitors — Scale"), que é a convenção do catálogo inteiro. Sem
180
+ * casar nenhum, cai no primeiro: é melhor um nome aproximado do que
181
+ * nenhum, e é o comportamento que existia.
182
+ */
183
+ const subscriptions = data.subscriptions || []
184
+ const own = subscriptions.find(sub => typeof sub?.planName === 'string' && sub.planName.toLowerCase().startsWith(product))
185
+ const plan = (own || subscriptions[0])?.planName || null
186
+
187
+ /*
188
+ * TWO CASES USED TO LOOK THE SAME HERE, and telling them apart is
189
+ * the whole fix. `hasActiveSubscription` is GLOBAL — one Payments
190
+ * project bills the stack — so it says `true` for a subscriber of
191
+ * ANY product. Finding no quantity slug therefore meant either:
192
+ *
193
+ * 1. A plan OF THIS PRODUCT with an incomplete catalog entry. Our
194
+ * fault, not the customer's: refusing would be charging without
195
+ * delivering, so this allows.
196
+ * 2. A subscription to ANOTHER product — the leak described at the
197
+ * top of this file.
198
+ *
199
+ * The product PREFIX separates them, because it is what says which
200
+ * product was paid for.
201
+ */
202
+ const paysForThis = [...slugs].some(s => s.startsWith(`${product}-`))
203
+
204
+ if (matched.length === 0) {
205
+ /*
206
+ * `otherProductPlan` PRESERVA O NOME DO PLANO ALHEIO, e a
207
+ * distinção custa uma linha mas muda a mensagem que o cliente
208
+ * lê.
209
+ *
210
+ * Quem paga Hoster — Scale e é barrado aqui não pode ler "o
211
+ * plano gratuito inclui 1 monitor": ele ESTÁ pagando, e uma
212
+ * frase que ignora isso soa como erro de cobrança. O que é
213
+ * verdade é que o plano dele é de outro produto — e dizer isso
214
+ * exige saber qual.
215
+ *
216
+ * `plan` continua `freePlanName` porque é o plano DESTE
217
+ * produto, e não há nenhum; o nome alheio viaja num campo
218
+ * próprio, para nunca ser confundido com um direito.
219
+ */
220
+ if (!paysForThis) return { plan: freePlanName, otherProductPlan: plan, limit: free, subscribed: false, unknown: false, slugs }
221
+ console.warn(`[plan] ${ownerId} has a ${product} plan with no quantity slug — allowing (catalog divergence)`)
222
+ return { plan, limit: Infinity, subscribed: true, unknown: false, slugs }
223
+ }
224
+
225
+ /*
226
+ * THE LARGEST WINS. An upgrade mid-cycle leaves both slugs, and
227
+ * charging for two plans while delivering the smaller one is the
228
+ * expensive mistake in this direction.
229
+ */
230
+ return { plan, limit: Math.max(...matched.map(t => t.limit)), subscribed: true, unknown: false, slugs }
231
+ } catch (error) {
232
+ console.error(`[plan] could not reach Payments for ${ownerId} — allowing:`, error?.message)
233
+ return unknown()
234
+ }
235
+ }
236
+
237
+ /**
238
+ * What this owner is entitled to. The single entry point, and the ONLY
239
+ * place a decision is recorded — the counting wraps the whole function
240
+ * rather than sitting beside each `return`, so a path added tomorrow cannot
241
+ * forget to be counted.
242
+ */
243
+ async function getEntitlements(cfg, ownerId) {
244
+ /*
245
+ * A CACHE HIT IS NOT A DECISION. The blind rate is blind decisions over
246
+ * decisions actually MADE; counting hits would pad the denominator with
247
+ * reads that asked nothing, so the more we cache, the healthier a
248
+ * broken gate would look.
249
+ */
250
+ const cached = ownerId ? readCache(ownerId) : null
251
+ if (cached) return cached
252
+
253
+ const entitlements = await resolve(cfg, ownerId)
254
+ recordGateDecision(!entitlements.unknown)
255
+ if (ownerId) writeCache(ownerId, entitlements)
256
+
257
+ return entitlements
258
+ }
259
+
260
+ /**
261
+ * Can this owner create one more?
262
+ *
263
+ * ONLY on the path that creates. Renaming or editing something that already
264
+ * exists must never hit a QUANTITY limit — whoever is over their plan keeps
265
+ * managing what they have, they just cannot add the next one.
266
+ */
267
+ async function canCreate(cfg, ownerId, current) {
268
+ /*
269
+ * NO OWNER IS NOT A FREE OWNER.
270
+ *
271
+ * Missing `ownerId` is absence of a subject, not uncertainty. `resolve`
272
+ * answers `noPlan()` for it, which carries the free floor — so a route
273
+ * that forgot to require auth would create the free resource with no
274
+ * owner attached, in every product at once. Closed here rather than in
275
+ * each product, because the mistake is the same everywhere.
276
+ */
277
+ if (!ownerId) {
278
+ return { allowed: false, plan: null, otherProductPlan: null, current, limit: free, resource, code: 'SUBSCRIPTION_REQUIRED' }
279
+ }
280
+
281
+ const ent = await getEntitlements(cfg, ownerId)
282
+
283
+ if (current < ent.limit) return { allowed: true, plan: ent.plan }
284
+
285
+ return {
286
+ allowed: false,
287
+ plan: ent.plan,
288
+ // O plano de OUTRO produto, quando existe: é o que permite dizer
289
+ // "a assinatura X é de outro produto" em vez de "o plano gratuito".
290
+ otherProductPlan: ent.otherProductPlan ?? null,
291
+ current,
292
+ limit: ent.limit === Infinity ? null : ent.limit,
293
+ resource,
294
+ /*
295
+ * TWO CODES, because there are two ways out. `PLAN_LIMIT` is "your
296
+ * plan ran out" — upgrade. `SUBSCRIPTION_REQUIRED` is "there is no
297
+ * plan" — subscribe, or settle a failed payment. Both are 402, and
298
+ * telling somebody to upgrade a plan they never had is how a screen
299
+ * becomes a support ticket.
300
+ */
301
+ code: ent.subscribed ? 'PLAN_LIMIT' : 'SUBSCRIPTION_REQUIRED',
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Drops an owner's cached answer, so the next read asks again.
307
+ *
308
+ * Call it from whatever changes the ANSWER — subscribing, cancelling,
309
+ * returning from checkout. NOT from whatever changes the COUNT: the cache
310
+ * holds the plan's ceiling, and the count is read fresh from the database
311
+ * every time.
312
+ *
313
+ * With no argument it clears everything, which is what tests need — the
314
+ * cache is state that otherwise leaks between cases.
315
+ */
316
+ function invalidate(ownerId) {
317
+ if (ownerId) cache.delete(ownerId)
318
+ else cache.clear()
319
+ }
320
+
321
+ return { getEntitlements, canCreate, invalidate, product, resource, free }
322
+ }
323
+
324
+ // ============================================================================
325
+ // READING SLUGS — for the gates that are not about quantity
326
+ // ============================================================================
327
+ //
328
+ // Every non-quantity gate in the stack turns out to be one of two questions
329
+ // about `ent.slugs`: "does it have this one?" (Hoster's `canDeploy`) or "which
330
+ // tier does it reach?" (Hoster's `getRetention`, Monitors' status pages).
331
+ //
332
+ // They live here because writing them by hand is what produced the Monitors
333
+ // defect: `monitors_get_account` carried its own copy of the pages rule,
334
+ // `!ent.subscribed ? 1 : !ent.slugs ? Infinity : Infinity`, whose last two
335
+ // branches are identical. Every subscriber was told "unlimited status pages",
336
+ // including Starter, whose slug caps them at one. The gate itself was right, so
337
+ // the agent was promised what the server would refuse — and nothing errored.
338
+ //
339
+ // The product still names its own gate. What it stops owning is the reading.
340
+
341
+ /**
342
+ * Does this entitlement carry a given feature slug?
343
+ *
344
+ * `unknown` answers TRUE: could-not-ask releases, like everywhere else in this
345
+ * file. A gate that closed here would turn a Payments outage into an outage of
346
+ * the feature.
347
+ */
348
+ export function hasSlug(ent, slug) {
349
+ if (ent?.unknown) return true
350
+ return Boolean(ent?.slugs?.has(slug))
351
+ }
352
+
353
+ /**
354
+ * The largest tier this entitlement reaches, or `free` when it reaches none.
355
+ *
356
+ * @param {Array<{slug: string, limit: number}>} tiers
357
+ * @param {number} free Floor for whoever matches no tier.
358
+ */
359
+ export function tierLimit(ent, tiers, free = 1) {
360
+ if (ent?.unknown) return Infinity
361
+
362
+ const matched = ent?.slugs ? tiers.filter(t => ent.slugs.has(t.slug)).map(t => t.limit) : []
363
+
364
+ return matched.length ? Math.max(...matched) : free
365
+ }
@@ -0,0 +1,28 @@
1
+ // ============================================================================
2
+ // @ciromaciel/contract/plan — the plan gate of the seven products
3
+ // ============================================================================
4
+ //
5
+ // import { createPlanGate } from '@ciromaciel/contract/plan'
6
+ //
7
+ // export const plan = createPlanGate({
8
+ // product: 'auth', // the prefix proving THIS product was paid for
9
+ // resource: 'applications', // what is counted, for the error payload
10
+ // tiers: [
11
+ // { slug: 'auth-applications-1', limit: 1 },
12
+ // { slug: 'auth-applications-5', limit: 5 },
13
+ // { slug: 'auth-applications-unlimited', limit: Infinity },
14
+ // ],
15
+ // })
16
+ //
17
+ // A product owns three things, and they are the three genuinely its own: the
18
+ // prefix, the slugs, and the wording of the notice. The cache, the fail-open,
19
+ // the product-prefix check, the telemetry and the choice between PLAN_LIMIT and
20
+ // SUBSCRIPTION_REQUIRED are the same reasoning everywhere, so they can only be
21
+ // right or wrong once.
22
+ //
23
+ // The rule itself — including when a product should freeze rather than warn —
24
+ // is in docs/2026-09-16-gate-de-plano.md.
25
+
26
+ export { createPlanGate, hasSlug, tierLimit } from './entitlements.js'
27
+ export { getPlanNotice, withPlanNotice } from './notice.js'
28
+ export { gateTelemetry, recordGateDecision, resetGateTelemetry } from './telemetry.js'
@@ -0,0 +1,98 @@
1
+ // ============================================================================
2
+ // notice.js — the billing warning the dashboard and the MCP both read
3
+ // ============================================================================
4
+ //
5
+ // FREEZE, DO NOT DISCONNECT. When a subscription lapses, the people who would
6
+ // be cut off are usually not the people who can pay: an application's end
7
+ // users, a site's visitors, a monitored service's audience. So nothing goes
8
+ // down. Growth freezes — the NEXT thing cannot be created — and the owner is
9
+ // told, in the two places an owner looks.
10
+ //
11
+ // THE MCP IS WARNED, NEVER BLOCKED. An agent whose tool returns an error cannot
12
+ // tell "the owner must pay" from "this integration is broken": it retries, it
13
+ // falls back, or it reports an outage. None of those reaches the person who can
14
+ // act. A notice attached to a SUCCESSFUL result is read and relayed.
15
+ //
16
+ // The three states, and why `unknown` is silent, are in `entitlements.js`.
17
+
18
+ /**
19
+ * Describes an owner's plan for display. It NEVER decides access.
20
+ *
21
+ * @param {object} gate What `createPlanGate` returned.
22
+ * @param {object} cfg Where the secret and the Payments URL live.
23
+ * @param {string} ownerId
24
+ * @param {object} [copy] Product wording. `product` is the name a person reads
25
+ * ("RiLiGar Auth"), `keeps` is what goes on working,
26
+ * `frozen` is what does not.
27
+ * @returns {Promise<{state: 'active'|'none'|'unknown', plan: string|null,
28
+ * limit: number|null, message: string|null}>}
29
+ */
30
+ export async function getPlanNotice(gate, cfg, ownerId, copy = {}) {
31
+ const ent = await gate.getEntitlements(cfg, ownerId)
32
+
33
+ // `Infinity` does not survive JSON.stringify — it becomes `null` silently.
34
+ // Converting on purpose is what keeps the wire honest.
35
+ const limit = ent.limit === Infinity ? null : ent.limit
36
+
37
+ // Could not ask: say nothing. Dunning somebody because our OWN billing call
38
+ // timed out is worse than staying quiet for the length of an outage.
39
+ if (ent.unknown) return { state: 'unknown', plan: null, limit: null, message: null }
40
+
41
+ if (ent.subscribed) return { state: 'active', plan: ent.plan, limit, message: null }
42
+
43
+ /*
44
+ * ONE MESSAGE, WRITTEN ONCE, for both surfaces — a banner and an MCP
45
+ * warning that disagree is how a customer ends up arguing with support
46
+ * about which is true.
47
+ *
48
+ * IT SAYS WHAT STILL WORKS BEFORE WHAT DOES NOT. The first question of
49
+ * anyone who sees this is "did my users just go down?", and an answer that
50
+ * opens with the refusal makes them assume the worst.
51
+ *
52
+ * Portuguese, because this is the one string here a person reads.
53
+ */
54
+ const product = copy.product || 'este produto'
55
+ const keeps = copy.keeps || 'O que você já criou continua funcionando'
56
+ const frozen = copy.frozen || 'apenas a criação de novos recursos está bloqueada'
57
+
58
+ return {
59
+ state: 'none',
60
+ plan: ent.plan,
61
+ limit,
62
+ message: `Sua assinatura do ${product} não está ativa. ${keeps} — ${frozen} até a regularização do pagamento.`,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Attaches the notice to an MCP tool result, without ever refusing it.
68
+ *
69
+ * IT TRAVELS AS A FIELD, NOT AS PROSE. The stack's MCP rule is that
70
+ * `content[0].text` is the `JSON.stringify` of the same object in
71
+ * `structuredContent`. Appending a second text block would break that mirror,
72
+ * and a client reading one field would disagree with one reading the other.
73
+ *
74
+ * ERRORS ARE LEFT ALONE: an `isError` result already carries the reason the
75
+ * agent needs, and billing copy on top of a permission failure buries it.
76
+ *
77
+ * AND IT NEVER BREAKS A TOOL CALL — the notice is decoration, so an unreachable
78
+ * Payments or an outright throw both leave the result untouched.
79
+ */
80
+ export async function withPlanNotice(result, gate, cfg, ownerId, copy) {
81
+ if (!result || result.isError || !result.structuredContent) return result
82
+
83
+ try {
84
+ const notice = await getPlanNotice(gate, cfg, ownerId, copy)
85
+ if (notice.state !== 'none') return result
86
+
87
+ const payload = { ...result.structuredContent, planNotice: notice }
88
+
89
+ return {
90
+ ...result,
91
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
92
+ structuredContent: payload,
93
+ }
94
+ } catch (error) {
95
+ console.error('[plan] could not resolve the notice — continuing without it:', error?.message)
96
+ return result
97
+ }
98
+ }
@@ -0,0 +1,68 @@
1
+ // ============================================================================
2
+ // telemetry.js — how often the gate decides blind
3
+ // ============================================================================
4
+ //
5
+ // THE GATE FAILS OPEN, and that is right: Payments down, a timeout or a missing
6
+ // secret all release. An incident in billing must not become an incident in
7
+ // every product.
8
+ //
9
+ // WHAT IS WRONG IS THE SILENCE. The 2026-09-15 survey of the stack found this
10
+ // to be the largest revenue hole: the whole stack could be giving itself away
11
+ // for days and nothing anywhere would say so. It does not close by turning the
12
+ // fail-open off — it closes by making it VISIBLE.
13
+ //
14
+ // A ONE-HOUR SLIDING WINDOW, because that is the question being asked: "is the
15
+ // gate deciding blind RIGHT NOW?". A counter accumulated since boot answers
16
+ // "has it ever", which is always yes after the first timeout and never returns
17
+ // to zero — a number that can only go up is one nobody can assert on.
18
+ //
19
+ // IN MEMORY AND PER ISOLATE. The number exists to raise a hand, not for
20
+ // accounting. A `/status` answering from any isolate shows that isolate's
21
+ // window, and if Payments is down they will all be counting.
22
+
23
+ /** When each blind decision happened, oldest first. */
24
+ const blind = []
25
+
26
+ /** When EVERY decision happened, so the rate means something. */
27
+ const total = []
28
+
29
+ const WINDOW_MS = 3600_000
30
+
31
+ /** Drops what fell out of the window. Called on every read and write. */
32
+ function trim(list, now) {
33
+ const cutoff = now - WINDOW_MS
34
+ while (list.length && list[0] < cutoff) list.shift()
35
+ }
36
+
37
+ /**
38
+ * Records one decision of the gate.
39
+ *
40
+ * `known` is `false` when the answer from Payments could not be obtained or
41
+ * read — the path that returns Infinity and releases.
42
+ */
43
+ export function recordGateDecision(known) {
44
+ const now = Date.now()
45
+ total.push(now)
46
+ if (!known) blind.push(now)
47
+ trim(total, now)
48
+ trim(blind, now)
49
+ }
50
+
51
+ /**
52
+ * The last hour, for `/status`.
53
+ *
54
+ * `gateBlindLastHour` is the number a monitor asserts on: anything above zero
55
+ * means somebody was granted access without us knowing whether they could.
56
+ */
57
+ export function gateTelemetry() {
58
+ const now = Date.now()
59
+ trim(total, now)
60
+ trim(blind, now)
61
+ return { gateDecisionsLastHour: total.length, gateBlindLastHour: blind.length }
62
+ }
63
+
64
+ /** For the tests. */
65
+ export function resetGateTelemetry() {
66
+ blind.length = 0
67
+ total.length = 0
68
+ }
@@ -0,0 +1,189 @@
1
+ // ============================================================================
2
+ // RESPONSES — the only way out of the server
3
+ // ============================================================================
4
+ //
5
+ // Four functions. If a handler in any of the seven products builds a
6
+ // `Response` by hand, it has stepped outside the contract.
7
+ //
8
+ // The point of this file is not to save typing — it is to make the defect
9
+ // IMPOSSIBLE. `failure()` derives the status from the code: there is no
10
+ // signature that lets you return `NOT_FOUND` with a 200. That discordant pair
11
+ // was exactly what made an agent read 200, conclude success, and carry on with
12
+ // data that did not exist.
13
+ import { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus } from './codes.js'
14
+
15
+ const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }
16
+
17
+ const respond = (body, status, headers) => new Response(JSON.stringify(body), { status, headers: { ...JSON_HEADERS, ...headers } })
18
+
19
+ /**
20
+ * One resource, at the root.
21
+ *
22
+ * ok({ id: 'mon_a1', name: 'checkout' })
23
+ * → 200 {"id":"mon_a1","name":"checkout"}
24
+ *
25
+ * No `data`, no `success`, no `message`. The HTTP status already said it went
26
+ * well; repeating that in the body is noise the agent has to learn to ignore —
27
+ * once per product.
28
+ */
29
+ export const ok = (resource, { status = 200, headers } = {}) => respond(resource, status, headers)
30
+
31
+ /**
32
+ * A resource that has just been BORN.
33
+ *
34
+ * 201 is the creation signal, and nothing else. An upsert that updated returns
35
+ * `ok()`; the one that created returns `created()`. That is what lets the agent
36
+ * know whether its own call was the one that created — without having to
37
+ * compare timestamps.
38
+ */
39
+ export const created = (resource, { headers } = {}) => respond(resource, 201, headers)
40
+
41
+ /**
42
+ * A collection, always in the same shape.
43
+ *
44
+ * collection([a, b], { limit: 50, offset: 0, total: 128 })
45
+ * → {"items":[…],"page":{"limit":50,"offset":0,"total":128,"hasMore":true}}
46
+ *
47
+ * `items` is always an array — empty is a legitimate response and has the SAME
48
+ * shape as a full one. Pruning the empty list (which Payments used to do) broke
49
+ * on the very first client, because every test environment has seeded data.
50
+ *
51
+ * `hasMore` is explicit on purpose: the consumer should not have to compare
52
+ * `offset + limit` against `total` to know whether to keep paginating.
53
+ *
54
+ * ────────────────────────────────────────────────────────────────────────────
55
+ * `total: null` WHEN NOBODY COUNTED — and that is the whole point of this helper.
56
+ *
57
+ * Until 09/10 the defaults filled `page` from the array itself: a route that
58
+ * NEVER paginated returned `{limit: N, offset: 0, total: N, hasMore: false}`,
59
+ * byte for byte identical to a route that paginated and reached the end. Of the
60
+ * stack's 39 collection routes, 29 did not paginate and all 29 asserted
61
+ * `hasMore: false` — the very field that exists so the agent need not deduce
62
+ * was deducing wrong in 87% of cases.
63
+ *
64
+ * Now: whoever does not pass `total` gets `total: null` and `hasMore: null`.
65
+ * Null means "I don't know", and that differs from zero and from false. An
66
+ * agent reading `total: null` knows it must ask another way; one reading
67
+ * `hasMore: false` stops paginating — and those two conclusions must not come
68
+ * out of the same response.
69
+ *
70
+ * Whoever truly paginates passes `total` and gets `hasMore` computed. Whoever
71
+ * returns the whole list on purpose passes `total: items.length`, and then
72
+ * `hasMore: false` is an assertion, not a default.
73
+ */
74
+ export const collection = (list, page = {}, { status = 200, headers } = {}) => {
75
+ const items = Array.isArray(list) ? list : []
76
+ const limit = page.limit ?? items.length
77
+ const offset = page.offset ?? 0
78
+
79
+ // `null` only when NOBODY counted. `total: 0` is a legitimate count.
80
+ const total = page.total ?? null
81
+ const hasMore = page.hasMore ?? (total === null ? null : offset + items.length < total)
82
+
83
+ /*
84
+ * `extra` goes at the ROOT, not inside `page`. Hoster's `activeDeployId`
85
+ * and Monitors' `counts` are context about the SET, not about pagination —
86
+ * inside `page` they would claim to be pagination, and a generic client
87
+ * reading `page` to paginate would trip over them.
88
+ */
89
+ return respond({ items, page: { limit, offset, total, hasMore }, ...(page.extra ?? {}) }, status, headers)
90
+ }
91
+
92
+ /**
93
+ * A failure. The status comes from the CODE — the caller does not pick the number.
94
+ *
95
+ * failure(CODES.NOT_FOUND, 'Project not found.')
96
+ * → 404 {"error":{"code":"NOT_FOUND","message":"Project not found."}}
97
+ *
98
+ * `details` is for what the agent can ACT on: the limit that was exceeded, the
99
+ * field that was missing, how many seconds to wait. Never for a stack trace.
100
+ *
101
+ * An unknown code becomes INTERNAL_ERROR rather than a silent 500 with a
102
+ * malformed body: if someone wrote a code outside the enum, the bug is ours,
103
+ * and the response to the client stays well formed.
104
+ */
105
+ export const failure = (code, message, { details, headers, status } = {}) => {
106
+ const resolved = toCode(code)
107
+ const body = { error: { code: resolved, message: String(message ?? '') } }
108
+
109
+ if (details && Object.keys(details).length) body.error.details = details
110
+ if (RETRIABLE.has(resolved)) body.error.retriable = true
111
+
112
+ return respond(body, status ?? STATUS_BY_CODE[resolved], headers)
113
+ }
114
+
115
+ /**
116
+ * The error body, without the `Response` around it.
117
+ *
118
+ * For whoever answers through a framework that assembles the response itself —
119
+ * the Elysia of Storage and Functions, where the handler returns an object and
120
+ * the status travels via `set.status`. Returns `{ body, status }` so the two go
121
+ * together and there is no way to forget one.
122
+ */
123
+ export const failureBody = (code, message, details) => {
124
+ const resolved = toCode(code)
125
+ const error = { code: resolved, message: String(message ?? '') }
126
+
127
+ if (details && Object.keys(details).length) error.details = details
128
+ if (RETRIABLE.has(resolved)) error.retriable = true
129
+
130
+ return { body: { error }, status: STATUS_BY_CODE[resolved] }
131
+ }
132
+
133
+ /**
134
+ * Reads `limit`/`offset` from a URL, with a ceiling.
135
+ *
136
+ * A `limit` with no ceiling is a way to take the service down with one query; a
137
+ * ceiling with no `offset` is what made record 101 of Messages unreachable. The
138
+ * two travel together.
139
+ */
140
+ export const pagination = (url, { defaultLimit = 50, maxLimit = 200 } = {}) => {
141
+ const p = url instanceof URL ? url.searchParams : new URL(url, 'http://x').searchParams
142
+ const rawLimit = Number(p.get('limit'))
143
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), maxLimit) : defaultLimit
144
+ const rawOffset = Number(p.get('offset'))
145
+ const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0
146
+ return { limit, offset }
147
+ }
148
+
149
+ /**
150
+ * The page of a paginated query, ready for `collection`.
151
+ *
152
+ * It exists to make MECHANICAL what the stack got wrong by hand on two fronts:
153
+ *
154
+ * 1. **The reported `limit` must be the APPLIED one.** Two routes clamped in
155
+ * the data layer and reported the raw query value: with `?limit=5000` they
156
+ * returned 200 rows and said `page:{limit:5000, hasMore:false}`. The client
157
+ * concludes "I asked for 5000, got 200, therefore it's over" — and stops
158
+ * paginating halfway.
159
+ *
160
+ * 2. **`total` comes from whoever counted.** Passing `total` is mandatory here,
161
+ * because `collection` on its own has no way to tell whether nobody counted
162
+ * or the count happened to equal the array length.
163
+ *
164
+ * const { limit, offset } = pagination(url)
165
+ * const rows = await fetchRows(limit, offset)
166
+ * const [{ total }] = await count()
167
+ * return collection(rows, page({ limit, offset, total }))
168
+ */
169
+ export const page = ({ limit, offset = 0, total, extra }) => {
170
+ if (!Number.isFinite(total)) {
171
+ throw new TypeError('page() requires a numeric `total` — use collection(items) directly when nobody counted.')
172
+ }
173
+ return { limit, offset, total, extra }
174
+ }
175
+
176
+ /**
177
+ * A failure for which only the HTTP status is known.
178
+ *
179
+ * For the global error handler, where a `throw { status, message }` arrives
180
+ * from any layer and there is no code at hand. It derives the `code` from the
181
+ * number instead of always falling into `INTERNAL_ERROR` — a 401 that turns
182
+ * into a 500 at the edge hides the real problem from whoever is debugging.
183
+ *
184
+ * `status` is preserved: whoever already had the number keeps it, even when the
185
+ * canonical code maps to another (401 for `UNAUTHORIZED`, 402 for `PLAN_LIMIT`).
186
+ */
187
+ export const failureWithStatus = (status, message, details, headers) => failure(codeFromStatus(status), message, { details, headers, status })
188
+
189
+ export { CODES, STATUS_BY_CODE }
package/src/schemas.js ADDED
@@ -0,0 +1,81 @@
1
+ // ============================================================================
2
+ // SCHEMAS — the contract as an executable thing
3
+ // ============================================================================
4
+ //
5
+ // The same rules as `responses.js`, from the READER's side. They let the
6
+ // contract test assert the shape without reimplementing the assertion in every
7
+ // suite, and let a consumer validate what it received.
8
+ //
9
+ // Zod is a peer: the seven products already have it (v4), and embedding a
10
+ // second copy in a worker means paying the bundle cost twice.
11
+ import { z } from 'zod'
12
+
13
+ import { STATUS_BY_CODE } from './codes.js'
14
+
15
+ /** The code is the closed enum — no free-form strings. */
16
+ export const codeSchema = z.enum(Object.keys(STATUS_BY_CODE))
17
+
18
+ export const errorSchema = z.object({
19
+ error: z.object({
20
+ code: codeSchema,
21
+ message: z.string().min(1),
22
+ details: z.record(z.string(), z.unknown()).optional(),
23
+ retriable: z.boolean().optional(),
24
+ }),
25
+ })
26
+
27
+ export const pageSchema = z.object({
28
+ limit: z.number().int().nonnegative(),
29
+ offset: z.number().int().nonnegative(),
30
+ total: z.number().int().nonnegative(),
31
+ hasMore: z.boolean(),
32
+ })
33
+
34
+ /** `{items, page}` — a forma de TODA coleção. */
35
+ export const collectionSchema = item =>
36
+ z.object({
37
+ items: z.array(item ?? z.unknown()),
38
+ page: pageSchema,
39
+ })
40
+
41
+ /**
42
+ * The resource goes at the root, so there is no envelope to validate — what can
43
+ * be asserted is what it must NOT be.
44
+ *
45
+ * The three forbidden keys are exactly the envelopes the stack used to use. A
46
+ * resource that still carries `data`, `success` or `message` at the top is a
47
+ * product that has not migrated — and the contract test must say so in plain
48
+ * words, instead of passing because "an object came back".
49
+ */
50
+ export const resourceSchema = body =>
51
+ (body ?? z.object({}).loose()).refine(v => !v || typeof v !== 'object' || (!('data' in v) && !('success' in v) && !('message' in v)), {
52
+ message: 'a resource must not be wrapped in `data`, `success` or `message`',
53
+ })
54
+
55
+ const CAMEL = /^[a-z][a-zA-Z0-9]*$/
56
+
57
+ /**
58
+ * Every key in the body is camelCase, at any depth.
59
+ *
60
+ * Storage used to return `tenant_id` in the body and `tenantId` in the path of
61
+ * the SAME route. A consumer had to know, field by field, which vocabulary to
62
+ * use.
63
+ *
64
+ * Keys that are user DATA (the name of a collection, of an environment
65
+ * variable, of a header) do not follow the rule — they are not our contract.
66
+ * Hence `ignore`.
67
+ */
68
+ export const camelCaseKeys = (value, { ignore = [] } = {}, path = '') => {
69
+ const offenders = []
70
+ const walk = (v, path) => {
71
+ if (Array.isArray(v)) return v.forEach((x, i) => walk(x, `${path}[${i}]`))
72
+ if (!v || typeof v !== 'object') return
73
+ for (const [k, sub] of Object.entries(v)) {
74
+ const here = path ? `${path}.${k}` : k
75
+ if (!ignore.includes(k) && !CAMEL.test(k)) offenders.push(here)
76
+ walk(sub, here)
77
+ }
78
+ }
79
+ walk(value, path)
80
+ return offenders
81
+ }