@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 +38 -0
- package/package.json +40 -0
- package/src/codes.js +227 -0
- package/src/index.js +25 -0
- package/src/plan/entitlements.js +365 -0
- package/src/plan/index.js +28 -0
- package/src/plan/notice.js +98 -0
- package/src/plan/telemetry.js +68 -0
- package/src/responses.js +189 -0
- package/src/schemas.js +81 -0
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
|
+
}
|
package/src/responses.js
ADDED
|
@@ -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
|
+
}
|