@ciromaciel/contract 1.0.5 → 1.2.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 CHANGED
@@ -13,7 +13,8 @@ described below.
13
13
  bun add @ciromaciel/contract
14
14
  ```
15
15
 
16
- `zod` is an **optional** peer, needed only by the `/schemas` entrypoint.
16
+ `zod` is an **optional** peer, needed only by the `/schemas` entrypoint, and
17
+ `jose` only by `/token`.
17
18
 
18
19
  ## Quick start
19
20
 
@@ -92,6 +93,46 @@ the prefix, the slugs and the wording of the notice; the cache, the fail-open,
92
93
  the product-prefix check and the choice between `PLAN_LIMIT` and
93
94
  `SUBSCRIPTION_REQUIRED` are the same reasoning everywhere.
94
95
 
96
+ ### `@ciromaciel/contract/webhooks`
97
+
98
+ ```js
99
+ import { webhookHeaders, verifyWebhook, generateSigningSecret } from '@ciromaciel/contract/webhooks'
100
+
101
+ const headers = await webhookHeaders(secret, { id: event.id, body }) // x-ccm-id, x-ccm-timestamp, x-ccm-signature
102
+ const result = await verifyWebhook([secret, previousSecret], { id, timestamp, signature, body })
103
+ ```
104
+
105
+ One protocol for every webhook of the stack: Standard Webhooks with the `x-ccm`
106
+ prefix. The signature is `v1,<base64 HMAC-SHA256 of "id.timestamp.body">`, the
107
+ key is the base64 after `whsec_`, and a timestamp more than five minutes off is
108
+ refused. `verifyWebhook` takes several secrets so a rotation has a grace period,
109
+ and answers with a reason (`stale_timestamp`, `bad_signature`, …) instead of a
110
+ bare boolean. Also exports `signWebhook`, `secretPrefixOf` and
111
+ `SIGNATURE_TOLERANCE_SECONDS`.
112
+
113
+ ### `@ciromaciel/contract/token`
114
+
115
+ ```js
116
+ import { createTokenVerifier } from '@ciromaciel/contract/token'
117
+
118
+ const tokens = createTokenVerifier({ authUrl: 'https://auth.worker.myinfrastructure.click' })
119
+
120
+ await tokens.verifySession(bearer) // REST routes
121
+ await tokens.verifyAccessToken(bearer, { audience: 'https://hoster.worker.myinfrastructure.click/mcp' }) // MCP server
122
+ ```
123
+
124
+ The Auth signs sessions and OAuth access tokens with the same key and issuer;
125
+ only the claims tell them apart. `verifySession` accepts a person's session and
126
+ refuses an access token (`access_token_not_accepted`): a token consented for one
127
+ resource and one scope must never act as the whole account on a REST route.
128
+ `verifyAccessToken` accepts only an access token whose `aud` is this server
129
+ (`wrong_audience` otherwise, RFC 8707) and returns its `scopes`.
130
+
131
+ Both return `{ ok: true, subject, email, name, scopes, claims }` or
132
+ `{ ok: false, reason }`, never throw on a bad credential, and accept RS256 with
133
+ the `riligar-auth` issuer only. `jose` (5 or 6) is an **optional** peer, needed
134
+ only by this entrypoint.
135
+
95
136
  ## Links
96
137
 
97
138
  - [Website](https://ciromaciel.click)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciromaciel/contract",
3
- "version": "1.0.5",
3
+ "version": "1.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A single response envelope, a closed catalogue of error codes, and helpers that make answering outside it impossible.",
@@ -13,21 +13,28 @@
13
13
  ".": "./src/index.js",
14
14
  "./codes": "./src/codes.js",
15
15
  "./schemas": "./src/schemas.js",
16
- "./plan": "./src/plan/index.js"
16
+ "./plan": "./src/plan/index.js",
17
+ "./webhooks": "./src/webhooks.js",
18
+ "./token": "./src/token.js"
17
19
  },
18
20
  "scripts": {
19
21
  "test": "bun test tests/"
20
22
  },
21
23
  "peerDependencies": {
22
- "zod": "^4.0.0"
24
+ "zod": "^4.0.0",
25
+ "jose": "^5.0.0 || ^6.0.0"
23
26
  },
24
27
  "peerDependenciesMeta": {
25
28
  "zod": {
26
29
  "optional": true
30
+ },
31
+ "jose": {
32
+ "optional": true
27
33
  }
28
34
  },
29
35
  "devDependencies": {
30
- "zod": "^4.5.4"
36
+ "zod": "^4.5.4",
37
+ "jose": "^6.2.10"
31
38
  },
32
39
  "publishConfig": {
33
40
  "access": "public"
package/src/token.js ADDED
@@ -0,0 +1,146 @@
1
+ // ============================================================================
2
+ // @ciromaciel/contract/token — one way to read an Infrastructure Auth token
3
+ // ============================================================================
4
+ //
5
+ // import { createTokenVerifier } from '@ciromaciel/contract/token'
6
+ //
7
+ // const tokens = createTokenVerifier({ authUrl: 'https://auth.worker.myinfrastructure.click' })
8
+ //
9
+ // // REST routes: a person's session, and nothing else.
10
+ // const session = await tokens.verifySession(bearer)
11
+ //
12
+ // // The MCP server: an OAuth access token issued for THIS resource.
13
+ // const access = await tokens.verifyAccessToken(bearer, { audience: 'https://hoster.worker.myinfrastructure.click/mcp' })
14
+ //
15
+ // The Auth signs two kinds of token with the same key and the same issuer:
16
+ //
17
+ // session `{ sub, email, name, role }`, no `aud`. Held by the person
18
+ // (dashboard, CLI login, the e-mailed code flow).
19
+ // access token `{ sub, aud, scope, client_id, token_type: 'access_token' }`.
20
+ // Held by a third-party OAuth client, limited to one resource
21
+ // (`aud`, RFC 8707) and to what the person consented to
22
+ // (`scope`).
23
+ //
24
+ // Signature and issuer cannot tell them apart; only the claims can. Each
25
+ // product used to verify tokens with its own copy of this code — twelve
26
+ // copies — and the REST ones checked signature and issuer only. So an access
27
+ // token consented as "read" for one product's MCP server worked as a full
28
+ // session on the REST API of every product. This module is the one place that
29
+ // knows the difference, so it can only be right or wrong once.
30
+ //
31
+ // Results are values, never exceptions: `{ ok: true, ... }` or
32
+ // `{ ok: false, reason }`. A wrong credential is an answer the caller turns
33
+ // into 401 or 403, not a failure. `reason` is for logs and for choosing the
34
+ // status; the text shown to the caller stays generic.
35
+
36
+ import { createRemoteJWKSet, jwtVerify } from 'jose'
37
+
38
+ /**
39
+ * The `iss` the Auth writes into every token. It is a fixed name, not the
40
+ * Auth URL: passing the URL makes every verification fail.
41
+ */
42
+ export const AUTH_TOKEN_ISSUER = 'riligar-auth'
43
+
44
+ /** The only algorithm accepted. HS256 would let anyone holding a shared secret forge tokens. */
45
+ const ALGORITHMS = ['RS256']
46
+
47
+ /** Why a token was refused. */
48
+ export const TOKEN_REFUSAL = {
49
+ /** Bad signature, unknown key, expired, wrong issuer or no subject. */
50
+ INVALID: 'invalid_token',
51
+ /** An OAuth access token presented where only a session is accepted. */
52
+ ACCESS_TOKEN_NOT_ACCEPTED: 'access_token_not_accepted',
53
+ /** An access token issued for another resource. */
54
+ WRONG_AUDIENCE: 'wrong_audience',
55
+ }
56
+
57
+ // One remote key set per JWKS URL, for the life of the process (or isolate).
58
+ // `jose` caches the keys and refetches on an unknown `kid`, which is what keeps
59
+ // a key rotation from turning into a wave of 401s.
60
+ const remoteKeySets = new Map()
61
+
62
+ function remoteKeySet(authUrl) {
63
+ const url = `${String(authUrl).replace(/\/+$/, '')}/.well-known/jwks.json`
64
+ if (!remoteKeySets.has(url)) remoteKeySets.set(url, createRemoteJWKSet(new URL(url)))
65
+ return remoteKeySets.get(url)
66
+ }
67
+
68
+ const audiencesOf = claims => (Array.isArray(claims.aud) ? claims.aud : [claims.aud]).filter(Boolean)
69
+
70
+ /**
71
+ * Is this an OAuth access token?
72
+ *
73
+ * Either mark is enough: the Auth writes both, and a token with an audience
74
+ * was issued for one resource, whatever else it says.
75
+ */
76
+ export const isAccessToken = claims => audiencesOf(claims).length > 0 || claims.token_type === 'access_token'
77
+
78
+ const scopesOf = claims =>
79
+ String(claims.scope ?? '')
80
+ .split(' ')
81
+ .filter(Boolean)
82
+
83
+ function identity(claims) {
84
+ return { subject: claims.sub, email: claims.email, name: claims.name, scopes: scopesOf(claims), claims }
85
+ }
86
+
87
+ /**
88
+ * Builds the verifier for one Auth.
89
+ *
90
+ * @param {object} options
91
+ * @param {string} [options.authUrl] The Auth base URL; its JWKS is fetched from `/.well-known/jwks.json`.
92
+ * @param {string} [options.issuer] The expected `iss`. Defaults to {@link AUTH_TOKEN_ISSUER}.
93
+ * @param {Function} [options.keySet] A `jose` key set to use instead of the remote JWKS (tests).
94
+ */
95
+ export function createTokenVerifier({ authUrl, issuer = AUTH_TOKEN_ISSUER, keySet } = {}) {
96
+ if (!keySet && !authUrl) throw new Error('createTokenVerifier: authUrl is required')
97
+
98
+ async function verified(token) {
99
+ if (!token || typeof token !== 'string') return null
100
+ try {
101
+ const { payload } = await jwtVerify(token, keySet ?? remoteKeySet(authUrl), { algorithms: ALGORITHMS, issuer })
102
+ return payload.sub ? payload : null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
108
+ return {
109
+ /**
110
+ * A person's session — what REST routes accept.
111
+ *
112
+ * An OAuth access token is refused here even though its signature is
113
+ * valid: it was consented for one resource and one scope, and a REST
114
+ * route would treat it as the person's whole account.
115
+ *
116
+ * @returns {Promise<{ok: true, subject: string, email?: string, name?: string, scopes: string[], claims: object} | {ok: false, reason: string}>}
117
+ */
118
+ async verifySession(token) {
119
+ const claims = await verified(token)
120
+ if (!claims) return { ok: false, reason: TOKEN_REFUSAL.INVALID }
121
+ if (isAccessToken(claims)) return { ok: false, reason: TOKEN_REFUSAL.ACCESS_TOKEN_NOT_ACCEPTED }
122
+ return { ok: true, ...identity(claims) }
123
+ },
124
+
125
+ /**
126
+ * An OAuth access token issued for `audience` — what an MCP server accepts.
127
+ *
128
+ * `audience` is the resource this server answers as (RFC 8707); a list
129
+ * is accepted when one server answers under more than one identifier.
130
+ * A session has no audience, so it is refused here as `wrong_audience`.
131
+ *
132
+ * @param {string} token
133
+ * @param {{ audience: string | string[] }} options
134
+ * @returns {Promise<{ok: true, subject: string, email?: string, name?: string, scopes: string[], claims: object} | {ok: false, reason: string}>}
135
+ */
136
+ async verifyAccessToken(token, { audience } = {}) {
137
+ const expected = (Array.isArray(audience) ? audience : [audience]).filter(Boolean)
138
+ if (expected.length === 0) throw new Error('verifyAccessToken: audience is required')
139
+
140
+ const claims = await verified(token)
141
+ if (!claims) return { ok: false, reason: TOKEN_REFUSAL.INVALID }
142
+ if (!audiencesOf(claims).some(aud => expected.includes(aud))) return { ok: false, reason: TOKEN_REFUSAL.WRONG_AUDIENCE }
143
+ return { ok: true, ...identity(claims) }
144
+ },
145
+ }
146
+ }
@@ -0,0 +1,119 @@
1
+ // ============================================================================
2
+ // @ciromaciel/contract/webhooks — one protocol for every webhook of the stack
3
+ // ============================================================================
4
+ //
5
+ // import { webhookHeaders, verifyWebhook } from '@ciromaciel/contract/webhooks'
6
+ //
7
+ // Standard Webhooks (the scheme OpenAI and Anthropic use) with our `x-ccm`
8
+ // prefix. Three headers travel with the body:
9
+ //
10
+ // x-ccm-id: the event's own id (resending it is a no-op)
11
+ // x-ccm-timestamp: unix seconds; more than 5 minutes off is refused
12
+ // x-ccm-signature: `v1,<base64 HMAC-SHA256 of "id.timestamp.body">`,
13
+ // several separated by spaces while a secret rotates
14
+ //
15
+ // The HMAC key is the secret after `whsec_`, base64-decoded, exactly as the
16
+ // Standard Webhooks libraries do, so any of them signs for us once the header
17
+ // names are ours. The id and the timestamp are inside the signed string: a
18
+ // captured request cannot be replayed with a fresh timestamp or passed off as
19
+ // another event.
20
+ //
21
+ // WHY IT LIVES HERE. People wrote it first, to VERIFY what its sources send.
22
+ // Videos is the first product that EMITS in this protocol, twice (to People
23
+ // and to a customer's own endpoint), and Auth, Hoster and Payments will emit
24
+ // when they gain webhooks. One file, or four copies that drift — the plan gate
25
+ // already paid for that lesson.
26
+ //
27
+ // IT RUNS ON BOTH RUNTIMES: only Web Crypto, TextEncoder and btoa/atob.
28
+
29
+ /** How far a signature's timestamp may drift from now, in seconds. */
30
+ export const SIGNATURE_TOLERANCE_SECONDS = 300
31
+
32
+ const encoder = new TextEncoder()
33
+ const toBase64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes)))
34
+ const fromBase64 = value => Uint8Array.from(atob(value), c => c.charCodeAt(0))
35
+
36
+ /**
37
+ * A new signing secret: `whsec_` and 32 random bytes in base64, the Standard
38
+ * Webhooks shape. Whoever finds it in a config knows at a glance what it is.
39
+ */
40
+ export function generateSigningSecret() {
41
+ return `whsec_${toBase64(crypto.getRandomValues(new Uint8Array(32)))}`
42
+ }
43
+
44
+ /** The part of a secret that may be shown again: enough to recognise it. */
45
+ export const secretPrefixOf = secret => String(secret).slice(0, 12)
46
+
47
+ /**
48
+ * The HMAC key of a secret: the base64 after `whsec_`. A secret that is not
49
+ * base64 (hex from before the switch is valid base64 too) falls back to its
50
+ * raw bytes, so both sides still derive the same key.
51
+ */
52
+ function keyBytes(secret) {
53
+ try {
54
+ return fromBase64(String(secret).replace(/^whsec_/, ''))
55
+ } catch {
56
+ return encoder.encode(String(secret))
57
+ }
58
+ }
59
+
60
+ async function hmacBase64(secret, payload) {
61
+ const key = await crypto.subtle.importKey('raw', keyBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
62
+ return toBase64(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)))
63
+ }
64
+
65
+ /** The `x-ccm-signature` value for one delivery. */
66
+ export async function signWebhook(secret, { id, timestamp, body }) {
67
+ return `v1,${await hmacBase64(secret, `${id}.${timestamp}.${body}`)}`
68
+ }
69
+
70
+ /**
71
+ * The three headers of one delivery, ready to send.
72
+ *
73
+ * @param {string} secret `whsec_…`
74
+ * @param {{id: string, body: string, timestamp?: number}} delivery
75
+ * @returns {Promise<Record<string, string>>}
76
+ */
77
+ export async function webhookHeaders(secret, { id, body, timestamp = Math.floor(Date.now() / 1000) }) {
78
+ return { 'x-ccm-id': String(id), 'x-ccm-timestamp': String(timestamp), 'x-ccm-signature': await signWebhook(secret, { id, timestamp, body }) }
79
+ }
80
+
81
+ /** Compares without leaking, through timing, how many leading characters matched. */
82
+ function timingSafeEqual(a, b) {
83
+ if (a.length !== b.length) return false
84
+ let diff = 0
85
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
86
+ return diff === 0
87
+ }
88
+
89
+ /**
90
+ * Is this body really from a holder of one of `secrets`? More than one while a
91
+ * rotation's grace lasts: the new secret and the one it replaced.
92
+ *
93
+ * Returns a reason instead of a boolean so the refusal can say what to fix;
94
+ * the reasons never echo the expected signature.
95
+ *
96
+ * @param {string[]} secrets
97
+ * @param {{id: string|null, timestamp: string|null, signature: string|null, body: string}} delivery
98
+ * @param {number} [now] unix seconds
99
+ * @returns {Promise<{ok: true} | {ok: false, reason: 'missing_headers'|'malformed_timestamp'|'stale_timestamp'|'malformed_signature'|'bad_signature'}>}
100
+ */
101
+ export async function verifyWebhook(secrets, { id, timestamp, signature, body }, now = Math.floor(Date.now() / 1000)) {
102
+ if (!id || !timestamp || !signature) return { ok: false, reason: 'missing_headers' }
103
+ const seconds = Number(timestamp)
104
+ if (!/^\d+$/.test(String(timestamp)) || !Number.isFinite(seconds)) return { ok: false, reason: 'malformed_timestamp' }
105
+ if (Math.abs(now - seconds) > SIGNATURE_TOLERANCE_SECONDS) return { ok: false, reason: 'stale_timestamp' }
106
+
107
+ const offered = String(signature)
108
+ .split(' ')
109
+ .map(part => part.trim())
110
+ .filter(part => part.startsWith('v1,'))
111
+ .map(part => part.slice(3))
112
+ if (!offered.length) return { ok: false, reason: 'malformed_signature' }
113
+
114
+ for (const secret of secrets) {
115
+ const expected = await hmacBase64(secret, `${id}.${seconds}.${body}`)
116
+ if (offered.some(value => timingSafeEqual(expected, value))) return { ok: true }
117
+ }
118
+ return { ok: false, reason: 'bad_signature' }
119
+ }