@ciromaciel/contract 1.1.0 → 1.3.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
 
@@ -109,6 +110,37 @@ and answers with a reason (`stale_timestamp`, `bad_signature`, …) instead of a
109
110
  bare boolean. Also exports `signWebhook`, `secretPrefixOf` and
110
111
  `SIGNATURE_TOLERANCE_SECONDS`.
111
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
+
136
+ ### `@ciromaciel/contract/outbox`
137
+
138
+ ```js
139
+ import { OUTBOX_SQL, envelope, enqueueEvent, deliverDue, releaseHeld, retryDelivery } from '@ciromaciel/contract/outbox'
140
+ ```
141
+
142
+ The delivery queue every product shares. Each event is a row in `outbox` before it leaves, one per subscribed endpoint, signed with `./webhooks`. A 2xx is `sent`; 401 and 403 are `held` (probed hourly, released at once by `releaseHeld` when the secret changes); 410 is `failed`; anything else retries after 1 min, 5 min, 30 min, 2 h and 12 h. `OUTBOX_SQL` is the schema to put in the product's first migration. The product supplies a D1-shaped `db` and `decryptSecret(ciphertext)`.
143
+
112
144
  ## Links
113
145
 
114
146
  - [Website](https://ciromaciel.click)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciromaciel/contract",
3
- "version": "1.1.0",
3
+ "version": "1.3.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.",
@@ -14,21 +14,28 @@
14
14
  "./codes": "./src/codes.js",
15
15
  "./schemas": "./src/schemas.js",
16
16
  "./plan": "./src/plan/index.js",
17
- "./webhooks": "./src/webhooks.js"
17
+ "./webhooks": "./src/webhooks.js",
18
+ "./token": "./src/token.js",
19
+ "./outbox": "./src/outbox.js"
18
20
  },
19
21
  "scripts": {
20
22
  "test": "bun test tests/"
21
23
  },
22
24
  "peerDependencies": {
23
- "zod": "^4.0.0"
25
+ "zod": "^4.0.0",
26
+ "jose": "^5.0.0 || ^6.0.0"
24
27
  },
25
28
  "peerDependenciesMeta": {
26
29
  "zod": {
27
30
  "optional": true
31
+ },
32
+ "jose": {
33
+ "optional": true
28
34
  }
29
35
  },
30
36
  "devDependencies": {
31
- "zod": "^4.5.4"
37
+ "zod": "^4.5.4",
38
+ "jose": "^6.2.10"
32
39
  },
33
40
  "publishConfig": {
34
41
  "access": "public"
package/src/outbox.js ADDED
@@ -0,0 +1,237 @@
1
+ // ============================================================================
2
+ // @ciromaciel/contract/outbox — the delivery queue every product shares
3
+ // ============================================================================
4
+ //
5
+ // import { OUTBOX_SQL, enqueueEvent, deliverDue, releaseHeld } from '@ciromaciel/contract/outbox'
6
+ //
7
+ // Every event a product emits leaves through webhook endpoints the account
8
+ // registers, signed with `./webhooks`. WRITTEN BEFORE IT LEAVES: each delivery
9
+ // is a row in `outbox` first (one per endpoint); trying it right away is a
10
+ // courtesy, and the cron is the guarantee.
11
+ //
12
+ // WHAT HAPPENS TO AN ATTEMPT:
13
+ //
14
+ // 2xx sent
15
+ // 401, 403 held: almost always a rotated secret or a revoked key on
16
+ // the other side. Retrying five times would only kill the
17
+ // delivery in 14 hours, while the fix is one paste away.
18
+ // It waits, probed once an hour, and leaves on its own
19
+ // when the endpoint answers 2xx again — or at once when
20
+ // the secret is replaced here (`releaseHeld`).
21
+ // 410 failed: the receiver said this address is gone.
22
+ // anything else retried after 1 min, 5 min, 30 min, 2 h and 12 h, then
23
+ // failed — visible on the screen and resendable by hand.
24
+ //
25
+ // WHY IT LIVES HERE. Videos wrote it, Community copied it, and four products
26
+ // were about to copy it again. One fix should not become six.
27
+ //
28
+ // IT ASKS THE PRODUCT FOR TWO THINGS ONLY: a D1-shaped `db` (prepare, bind,
29
+ // first, all, run, batch) and a `decryptSecret(ciphertext)` that returns the
30
+ // endpoint's `whsec_`. Envelopes are built by the product with `envelope()`.
31
+
32
+ import { webhookHeaders } from './webhooks.js'
33
+
34
+ /** Seconds before each retry of an ordinary failure. */
35
+ export const RETRY_DELAYS = [60, 300, 1800, 7200, 43_200]
36
+
37
+ /** How often a held delivery is probed, in seconds. */
38
+ export const HOLD_PROBE_SECONDS = 3600
39
+
40
+ const DELIVERY_TIMEOUT_MS = 10_000
41
+
42
+ /**
43
+ * The two tables, as products put them in their first migration. Same columns
44
+ * as Videos' `0002_webhook_endpoints.sql`, plus the `held` status.
45
+ */
46
+ export const OUTBOX_SQL = `
47
+ CREATE TABLE webhook_endpoints (
48
+ id TEXT PRIMARY KEY,
49
+ account_id TEXT NOT NULL,
50
+ url TEXT NOT NULL,
51
+ description TEXT,
52
+ events TEXT NOT NULL DEFAULT '["*"]', -- JSON array of types, or ["*"]
53
+ secret_ciphertext TEXT NOT NULL,
54
+ secret_prefix TEXT NOT NULL,
55
+ secret_source TEXT NOT NULL DEFAULT 'generated', -- generated | provided
56
+ is_active INTEGER NOT NULL DEFAULT 1,
57
+ last_status INTEGER,
58
+ last_at INTEGER,
59
+ created_at INTEGER NOT NULL,
60
+ updated_at INTEGER NOT NULL
61
+ );
62
+ CREATE INDEX idx_webhook_endpoints_account ON webhook_endpoints (account_id, created_at);
63
+
64
+ CREATE TABLE outbox (
65
+ id TEXT PRIMARY KEY,
66
+ account_id TEXT NOT NULL,
67
+ target TEXT NOT NULL, -- webhook_endpoints.id
68
+ event_id TEXT NOT NULL, -- x-ccm-id: resending is a no-op on the other side
69
+ type TEXT NOT NULL,
70
+ subject_id TEXT, -- what the event is about (a response, a link), to list deliveries per item
71
+ body TEXT NOT NULL,
72
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | held | failed
73
+ attempts INTEGER NOT NULL DEFAULT 0,
74
+ next_at INTEGER NOT NULL,
75
+ last_status INTEGER,
76
+ last_error TEXT,
77
+ created_at INTEGER NOT NULL,
78
+ sent_at INTEGER,
79
+ UNIQUE (target, event_id)
80
+ );
81
+ CREATE INDEX idx_outbox_due ON outbox (status, next_at);
82
+ CREATE INDEX idx_outbox_account ON outbox (account_id, created_at DESC);
83
+ CREATE INDEX idx_outbox_target ON outbox (target, created_at DESC);
84
+ CREATE INDEX idx_outbox_subject ON outbox (account_id, subject_id);
85
+ `
86
+
87
+ /** Whether an endpoint's subscription covers `type`. */
88
+ export function subscribes(endpoint, type) {
89
+ let events
90
+ try {
91
+ events = JSON.parse(endpoint.events ?? '["*"]')
92
+ } catch {
93
+ events = ['*']
94
+ }
95
+ return events.includes('*') || events.includes(type)
96
+ }
97
+
98
+ /**
99
+ * The envelope People reads from any source: `{type, timestamp, data}`, the
100
+ * person in `data.person`, where they came through in `data.channel`, the rest
101
+ * in `data.properties`. Empty person fields are dropped.
102
+ */
103
+ export function envelope({ type, at, channel = 'web', person = null, properties = {} }) {
104
+ const data = { channel, properties }
105
+ if (person) {
106
+ const clean = Object.fromEntries(Object.entries(person).filter(([, value]) => value !== undefined && value !== null && value !== ''))
107
+ if (Object.keys(clean).length) data.person = clean
108
+ }
109
+ return JSON.stringify({ type, timestamp: new Date(at * 1000).toISOString(), data })
110
+ }
111
+
112
+ /**
113
+ * What one attempt turns into. `attempts` counts this one.
114
+ *
115
+ * @returns {{status: 'sent'|'pending'|'held'|'failed', nextIn: number|null}}
116
+ */
117
+ export function classifyAttempt({ ok, status }, attempts) {
118
+ if (ok) return { status: 'sent', nextIn: null }
119
+ if (status === 401 || status === 403) return { status: 'held', nextIn: HOLD_PROBE_SECONDS }
120
+ if (status === 410) return { status: 'failed', nextIn: null }
121
+ if (attempts > RETRY_DELAYS.length) return { status: 'failed', nextIn: null }
122
+ return { status: 'pending', nextIn: RETRY_DELAYS[attempts - 1] }
123
+ }
124
+
125
+ /**
126
+ * Queues one event for every active endpoint of the account subscribed to its
127
+ * type. Returns the outbox ids, so the caller can try them at once.
128
+ *
129
+ * @param {object} db D1 or anything shaped like it
130
+ * @param {{accountId: string, eventId: string, type: string, body: string, at: number, subjectId?: string}} event
131
+ */
132
+ export async function enqueueEvent(db, { accountId, eventId, type, body, at, subjectId = null }) {
133
+ const { results: active } = await db.prepare('SELECT id, events FROM webhook_endpoints WHERE account_id = ? AND is_active = 1').bind(accountId).all()
134
+ const targets = active.filter(endpoint => subscribes(endpoint, type))
135
+ if (!targets.length) return []
136
+ const ids = []
137
+ const statements = targets.map(endpoint => {
138
+ const id = crypto.randomUUID()
139
+ ids.push(id)
140
+ return db
141
+ .prepare('INSERT OR IGNORE INTO outbox (id, account_id, target, event_id, type, subject_id, body, next_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
142
+ .bind(id, accountId, endpoint.id, eventId, type, subjectId, body, at, at)
143
+ })
144
+ await db.batch(statements)
145
+ return ids
146
+ }
147
+
148
+ async function post(fetchImpl, url, headers, body, userAgent) {
149
+ const started = Date.now()
150
+ try {
151
+ const response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': userAgent, ...headers }, body, signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS) })
152
+ return { status: response.status, ok: response.ok, ms: Date.now() - started, error: response.ok ? null : (await response.text()).slice(0, 300) }
153
+ } catch (error) {
154
+ return { status: 0, ok: false, ms: Date.now() - started, error: error?.name === 'TimeoutError' ? 'Sem resposta em 10 s.' : 'Não foi possível conectar.' }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * One signed POST to an endpoint: a delivery, or the "Enviar teste" button.
160
+ *
161
+ * @param {object} endpoint a `webhook_endpoints` row
162
+ * @param {{eventId: string, body: string}} delivery
163
+ * @param {{decryptSecret: (ciphertext: string) => Promise<string>, fetch?: typeof fetch, userAgent?: string}} options
164
+ */
165
+ export async function sendSigned(endpoint, { eventId, body }, { decryptSecret, fetch: fetchImpl = fetch, userAgent = 'ciromaciel-webhooks/1.0' }) {
166
+ const secret = await decryptSecret(endpoint.secret_ciphertext)
167
+ return post(fetchImpl, endpoint.url, await webhookHeaders(secret, { id: eventId, body }), body, userAgent)
168
+ }
169
+
170
+ /**
171
+ * Delivers due rows: those `ids`, or every pending or held row whose time has
172
+ * come. Returns counts for the cron's log.
173
+ *
174
+ * @param {object} db
175
+ * @param {{now: number, ids?: string[]|null, limit?: number, decryptSecret: Function, fetch?: typeof fetch, userAgent?: string}} options
176
+ */
177
+ export async function deliverDue(db, { now, ids = null, limit = 50, decryptSecret, fetch: fetchImpl = fetch, userAgent }) {
178
+ const rows = ids
179
+ ? ids.length
180
+ ? (
181
+ await db
182
+ .prepare(`SELECT * FROM outbox WHERE status IN ('pending', 'held') AND id IN (${ids.map(() => '?').join(',')})`)
183
+ .bind(...ids)
184
+ .all()
185
+ ).results
186
+ : []
187
+ : (await db.prepare("SELECT * FROM outbox WHERE status IN ('pending', 'held') AND next_at <= ? ORDER BY next_at LIMIT ?").bind(now, limit).all()).results
188
+ const endpoints = new Map()
189
+ const counts = { due: rows.length, sent: 0, held: 0, failed: 0, retrying: 0 }
190
+ for (const row of rows) {
191
+ if (!endpoints.has(row.target)) endpoints.set(row.target, await db.prepare('SELECT * FROM webhook_endpoints WHERE id = ? AND account_id = ?').bind(row.target, row.account_id).first())
192
+ const endpoint = endpoints.get(row.target)
193
+ if (!endpoint || !endpoint.is_active) {
194
+ await db
195
+ .prepare("UPDATE outbox SET status = 'failed', last_error = ? WHERE id = ?")
196
+ .bind(endpoint ? 'Endpoint desligado.' : 'Endpoint removido.', row.id)
197
+ .run()
198
+ counts.failed++
199
+ continue
200
+ }
201
+ const result = await sendSigned(endpoint, { eventId: row.event_id, body: row.body }, { decryptSecret, fetch: fetchImpl, userAgent })
202
+ // A held row probing again does not spend the ordinary retries.
203
+ const attempts = row.status === 'held' ? row.attempts : row.attempts + 1
204
+ const next = classifyAttempt(result, Math.max(attempts, 1))
205
+ if (next.status === 'sent') {
206
+ await db.prepare("UPDATE outbox SET status = 'sent', attempts = ?, last_status = ?, last_error = NULL, sent_at = ? WHERE id = ?").bind(row.attempts + 1, result.status, now, row.id).run()
207
+ counts.sent++
208
+ } else if (next.status === 'failed') {
209
+ await db.prepare("UPDATE outbox SET status = 'failed', attempts = ?, last_status = ?, last_error = ? WHERE id = ?").bind(row.attempts + 1, result.status, result.error, row.id).run()
210
+ counts.failed++
211
+ } else {
212
+ await db
213
+ .prepare('UPDATE outbox SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_at = ? WHERE id = ?')
214
+ .bind(next.status, attempts, result.status, result.error, now + next.nextIn, row.id)
215
+ .run()
216
+ if (next.status === 'held') counts.held++
217
+ else counts.retrying++
218
+ }
219
+ await db.prepare('UPDATE webhook_endpoints SET last_status = ?, last_at = ? WHERE id = ?').bind(result.status, now, endpoint.id).run()
220
+ }
221
+ return counts
222
+ }
223
+
224
+ /**
225
+ * Lets held deliveries of an endpoint go now — called when its secret or URL
226
+ * changes here, so the fix is felt at once instead of at the next probe.
227
+ */
228
+ export async function releaseHeld(db, { accountId, endpointId, now }) {
229
+ const result = await db.prepare("UPDATE outbox SET status = 'pending', next_at = ? WHERE account_id = ? AND target = ? AND status = 'held'").bind(now, accountId, endpointId).run()
230
+ return result?.meta?.changes ?? 0
231
+ }
232
+
233
+ /** Puts one failed or held delivery back in line, for the "Reenviar" button. */
234
+ export async function retryDelivery(db, { accountId, id, now }) {
235
+ const result = await db.prepare("UPDATE outbox SET status = 'pending', attempts = 0, next_at = ? WHERE id = ? AND account_id = ? AND status IN ('failed', 'held')").bind(now, id, accountId).run()
236
+ return (result?.meta?.changes ?? 0) > 0
237
+ }
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
+ }