@fabrika/auth 0.0.1

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/src/json.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Tiny structural readers for untrusted JSON — here, an IAM HTTP response body. They let the decoders
3
+ * in `rpc-http.ts` narrow without `as` casts: read a field, check its runtime type, proceed.
4
+ *
5
+ * Mirrors `@fabrika/auth-core`'s internal `json.ts` (not exported from its entry point) and
6
+ * `@fabrika/proxy`'s copy. Three tiny functions duplicated three times is cheaper than widening a
7
+ * published package's public API to share them.
8
+ */
9
+
10
+ /** Read a property off an unknown value (undefined when absent / not an object). */
11
+ export function prop(value: unknown, key: string): unknown {
12
+ if (typeof value !== 'object' || value === null) {
13
+ return undefined
14
+ }
15
+ return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined
16
+ }
17
+
18
+ export function stringField(value: unknown, key: string): string | undefined {
19
+ const v = prop(value, key)
20
+ return typeof v === 'string' ? v : undefined
21
+ }
22
+
23
+ export function numberField(value: unknown, key: string): number | undefined {
24
+ const v = prop(value, key)
25
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined
26
+ }
27
+
28
+ export function booleanField(value: unknown, key: string): boolean | undefined {
29
+ const v = prop(value, key)
30
+ return typeof v === 'boolean' ? v : undefined
31
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The shared middleware contract consumed by `@fabrika/app`. The auth package owns the shape so an
3
+ * app's own auth middleware — the one that calls `iam.authenticate(request)` and stores the resolved
4
+ * context — drops into the request pipeline without a duplicate compatibility interface. The SDK
5
+ * itself ships no middleware: enforcement is the proxy's job (ADR-0007).
6
+ *
7
+ * A middleware runs in order; it may:
8
+ * - mutate `ctx` (e.g. set `ctx.auth`),
9
+ * - short-circuit by returning a Response WITHOUT calling `next()`,
10
+ * - or wrap `next()` — await it, then append headers to the returned Response.
11
+ */
12
+
13
+ import type { AuthContext } from './types'
14
+
15
+ /**
16
+ * A request-pipeline middleware over a caller-owned context `Ctx`. Runs in registration order. Return
17
+ * a Response without awaiting `next()` to short-circuit; await `next()` and return its (possibly
18
+ * header-augmented) Response to continue.
19
+ */
20
+ export type Middleware<Ctx> = (request: Request, ctx: Ctx, next: () => Promise<Response>) => Promise<Response>
21
+
22
+ /**
23
+ * The minimal shape an auth middleware needs of the pipeline context: a writable `auth` slot it sets to
24
+ * the resolved `AuthContext` (or leaves null/undefined for an unresolved/anonymous caller). The
25
+ * caller's real context may carry far more — this is only what the auth middlewares touch.
26
+ */
27
+ export interface AuthCarrier {
28
+ auth?: AuthContext | null
29
+ }
@@ -0,0 +1,146 @@
1
+ // Deploy-time schema reconcile — authz-vocabulary as code.
2
+ //
3
+ // Each app OWNS its authz vocabulary (scope dimensions, action catalog, roles) and declares it
4
+ // in its own code as an `AppSchema`. At deploy time the app reconciles that declaration into
5
+ // IAM by PUTting it to the idempotent admin endpoint `PUT /admin/apps/:app/schema`, so the
6
+ // IAM Worker's DB always mirrors what the app actually checks at runtime. An app's FIRST schema
7
+ // reconcile is also how it REGISTERS itself in IAM's app registry.
8
+ //
9
+ // The same deploy step optionally projects the app's RETURN ORIGINS (ADR-0021) — see
10
+ // `returnOrigins` below. That is a SECOND call to a different endpoint, deliberately: the schema is
11
+ // vocabulary the application declares, a return origin is a fact the control plane knows, and the
12
+ // two must never travel in one body.
13
+ //
14
+ // This is a DEPLOY/OPERATOR helper, NOT a runtime one: it talks HTTP to the admin origin (not the
15
+ // `IAM` service binding) and authenticates with an IAM-issued `px_` ADMIN key as a bearer —
16
+ // reconcile is a privileged admin operation. Call it from a deploy step / provisioning script,
17
+ // never from request handling.
18
+ //
19
+ // Idempotent: the endpoint upserts scopes/actions/origin='app' roles and deletes app-origin rows
20
+ // absent from the body; origin='custom' policies are never touched. Re-running is safe, and so is
21
+ // re-sending the same return-origin set.
22
+
23
+ import type { AppSchema } from '@fabrika/auth-core'
24
+
25
+ export interface ReconcileSchemaOptions {
26
+ /** The IAM Worker's admin origin, e.g. `https://iam.example.com` (trailing slash ok). */
27
+ url: string
28
+ /** The app id; its FIRST reconcile registers it in IAM's app registry. */
29
+ app: string
30
+ /** The app's declared vocabulary. */
31
+ schema: AppSchema
32
+ /**
33
+ * Every origin IAM may hand this app a session at (ADR-0021), as known by the CONTROL PLANE.
34
+ *
35
+ * Omit to leave the registry alone; a supplied set REPLACES it, so it must be the complete set for
36
+ * the app and not just the environment being deployed. It is NEVER read off the `AppSchema`: an app
37
+ * that could name its own return origin could name any origin, which is the thing the registry
38
+ * exists to prevent. An empty array is a caller error — clearing the registry is its own admin
39
+ * operation, not an accident of having nothing to send.
40
+ */
41
+ returnOrigins?: readonly string[]
42
+ /**
43
+ * An IAM-issued `px_` ADMIN key, sent as `Authorization: Bearer`. Effectively required: IAM's CSRF
44
+ * guard exempts BEARER-ONLY callers, and nothing else — a credential-less server-side call sends
45
+ * neither `Origin` nor `Referer` and is refused whatever the admin-origin registry holds. The
46
+ * exemption rests on one fact, that a browser never attaches `Authorization` by itself; no such
47
+ * fact covers a credential-less request, and `resolveAdmin` would read it as a global admin.
48
+ */
49
+ adminKey?: string
50
+ /** Cancels the in-flight request when schema reconciliation belongs to a deploy lifecycle. */
51
+ signal?: AbortSignal
52
+ }
53
+
54
+ /** Thrown when the admin endpoint rejects the reconcile; `status` is the HTTP status. */
55
+ export class ReconcileSchemaError extends Error {
56
+ constructor(message: string, readonly status: number) {
57
+ super(message)
58
+ this.name = 'ReconcileSchemaError'
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Pull a human-readable message out of an admin failure body. REST answers `{ error: "…" }`;
64
+ * `/admin/rpc` answers `{ error: { type, message } }`. Null when neither shape is present.
65
+ */
66
+ function errorMessage(value: unknown): string | null {
67
+ if (typeof value !== 'object' || value === null || !('error' in value)) {
68
+ return null
69
+ }
70
+ const message = value.error
71
+ if (typeof message === 'string') {
72
+ return message
73
+ }
74
+ if (typeof message === 'object' && message !== null && 'message' in message && typeof message.message === 'string') {
75
+ return message.message
76
+ }
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Reconcile one app's declared `AppSchema` into IAM (idempotent), then — when `returnOrigins` is
82
+ * supplied — project that set into IAM's return-origin registry. Resolves on success; throws
83
+ * `ReconcileSchemaError` on a non-2xx response, or `Error` on a half-set service token.
84
+ */
85
+ export async function reconcileSchema(options: ReconcileSchemaOptions): Promise<void> {
86
+ const { url, app, schema, returnOrigins, adminKey, signal } = options
87
+
88
+ if (returnOrigins !== undefined && returnOrigins.length === 0) {
89
+ throw new Error('reconcileSchema: returnOrigins must not be empty — omit it to leave the registry untouched')
90
+ }
91
+
92
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
93
+ if (adminKey !== undefined) {
94
+ headers['authorization'] = `Bearer ${adminKey}`
95
+ }
96
+
97
+ // Trim a trailing slash so `${base}${path}` never doubles up.
98
+ const base = url.replace(/\/+$/, '')
99
+ const schemaPath = `/admin/apps/${encodeURIComponent(app)}/schema`
100
+ const response = await fetch(`${base}${schemaPath}`, {
101
+ method: 'PUT',
102
+ headers,
103
+ body: JSON.stringify(schema),
104
+ signal,
105
+ })
106
+ if (!response.ok) {
107
+ throw new ReconcileSchemaError(`PUT /admin/apps/${app}/schema failed (${response.status}): ${await detail(response)}`, response.status)
108
+ }
109
+
110
+ if (returnOrigins === undefined) {
111
+ return
112
+ }
113
+
114
+ // Second call, second endpoint, and it runs SECOND on purpose: `apps.setReturnOrigins` answers 404
115
+ // for an app IAM has never heard of, and the schema PUT above is what registers it.
116
+ const registration = await fetch(`${base}/admin/rpc`, {
117
+ method: 'POST',
118
+ headers,
119
+ body: JSON.stringify({ method: 'apps.setReturnOrigins', input: { app, origins: [...returnOrigins] } }),
120
+ signal,
121
+ })
122
+ const failure = registration.ok ? await rpcError(registration) : await detail(registration)
123
+ if (failure !== null) {
124
+ throw new ReconcileSchemaError(
125
+ `apps.setReturnOrigins for ${app} failed (${registration.status}): ${failure}`,
126
+ registration.status,
127
+ )
128
+ }
129
+ }
130
+
131
+ /** The admin API's own message for a failed response, falling back to the status text. */
132
+ async function detail(response: Response): Promise<string> {
133
+ const payload: unknown = await response.json().catch(() => null)
134
+ return errorMessage(payload) ?? response.statusText
135
+ }
136
+
137
+ /**
138
+ * A single `/admin/rpc` call maps its error onto the HTTP status, so a 2xx is already success. Read
139
+ * the envelope anyway: a body carrying `error` at 200 means the call was answered in a shape this
140
+ * caller did not ask for (a batch, say), and treating it as success would report a registration that
141
+ * never happened. Null when the envelope is clean.
142
+ */
143
+ async function rpcError(response: Response): Promise<string | null> {
144
+ const payload: unknown = await response.json().catch(() => null)
145
+ return errorMessage(payload)
146
+ }
package/src/request.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Reading the fabrika-native credential off the incoming Request, to forward to the IAM service's
2
+ // management RPCs (issueKey / issueJwt / revokeKey / listPrincipals). Precedence: the token the PROXY
3
+ // injected (the one it already verified), then an `Authorization: Bearer` (a machine `px_` key or a
4
+ // passthrough JWT). None of this is a security boundary —
5
+ // IAM re-resolves and re-authorizes the caller server-side from this credential.
6
+
7
+ import { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
8
+
9
+ /** Cloudflare's per-request ray id; we use it as the correlation request id. */
10
+ const RAY_HEADER = 'cf-ray'
11
+
12
+ export interface ForwardedCredentials {
13
+ /** The native credential (a `px_` key / passthrough JWT / `px_token`), or null when absent. */
14
+ credential: string | null
15
+ /** cf-ray, or a fresh UUID when absent (e.g. local dev). */
16
+ requestId: string
17
+ }
18
+
19
+ /** The correlation id for this request: cf-ray when the platform supplies one, else a fresh UUID. */
20
+ export function readRequestId(req: Request): string {
21
+ return req.headers.get(RAY_HEADER) ?? crypto.randomUUID()
22
+ }
23
+
24
+ /** Pull the native credential and a correlation id off the request. `credential` may be null. */
25
+ export function readCredentials(req: Request): ForwardedCredentials {
26
+ return {
27
+ credential: nonEmpty(req.headers.get(PROXY_TOKEN_HEADER))
28
+ ?? readBearer(req.headers.get('Authorization')),
29
+ requestId: readRequestId(req),
30
+ }
31
+ }
32
+
33
+ function nonEmpty(value: string | null): string | null {
34
+ if (value === null) {
35
+ return null
36
+ }
37
+ const trimmed = value.trim()
38
+ return trimmed === '' ? null : trimmed
39
+ }
40
+
41
+ /** Read the token out of an `Authorization: Bearer <token>` header. Null when absent/non-bearer. */
42
+ function readBearer(header: string | null): string | null {
43
+ if (header === null) {
44
+ return null
45
+ }
46
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim())
47
+ return match ? (match[1]?.trim() ?? null) : null
48
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * `IamRpc` over HTTP — the transport an app needs when it has no Cloudflare service binding.
3
+ *
4
+ * WHAT THIS IS NOT: it is not a second IAM. `IamRpc` (@fabrika/auth-core) is unchanged and this class
5
+ * holds no policy — it POSTs the method's input as JSON, reads the result back, and hands it to the
6
+ * same callers (`Iam`, `IamClient`) the binding serves. Everything downstream of the
7
+ * contract — local token verification, `can()`/`scopedTo()`, delegation checks inside the IAM
8
+ * service — is byte-for-byte the same code on both transports. The ONLY difference is how the
9
+ * call travels.
10
+ *
11
+ * THE WIRE IS `@fabrika/iam`'s `src/rpc-http.ts`, and that file is the contract:
12
+ *
13
+ * POST {origin}/rpc/<method> Authorization: Bearer <key> body = the method's input
14
+ * → 200 with the method's result (INCLUDING a decided `{ ok: false, reason }`)
15
+ * → 204 for `audit` (it returns void over the binding, so there is nothing to encode)
16
+ * → 401 bad/absent key · 404 no such method OR the surface is disabled (an unset key 404s) ·
17
+ * 400 undecodable body · 405 not a POST
18
+ *
19
+ * A DECIDED NEGATIVE IS A 200, and that distinction is the whole point: `{ ok: false, reason }` means
20
+ * "IAM answered: no", while a non-200 means "IAM could not be consulted". Only the second is an
21
+ * incident, and only the second throws here.
22
+ *
23
+ * ── Why a transport failure THROWS ────────────────────────────────────────────────────────────────
24
+ *
25
+ * Over a service binding an unreachable IAM is not a case a caller has to model: the binding either
26
+ * resolves or the whole invocation fails. Over HTTP it is a real state, and mapping it onto the
27
+ * contract's `reason` union would be a lie — `invalid_session` says "we checked, and no", which is not
28
+ * what happened. So it raises `IamTransportError`, callers see a 5xx, and nothing is ever admitted on
29
+ * the strength of an answer that never arrived. This is the same split `@fabrika/proxy`'s
30
+ * `HttpIamGateway` makes with `IamUnavailableError`.
31
+ *
32
+ * ── One instance per process, deliberately ────────────────────────────────────────────────────────
33
+ *
34
+ * The SDK caches the fetched JWKS and the tokens minted from `px_` share credentials in `WeakMap`s
35
+ * keyed by THE BINDING OBJECT. A fresh `HttpIamRpc` per request would therefore miss both caches every
36
+ * time and re-fetch the JWKS on every request. Build one for the process's lifetime and put it in the
37
+ * env, the way a binding lives for the isolate's lifetime.
38
+ *
39
+ * The key is a TRANSPORT credential, never a caller identity: it says "this request came from inside
40
+ * the deployment", exactly as a binding's unreachability does. Per-caller authorization still happens
41
+ * inside IAM, from the credential carried in the method's own input. It is never logged.
42
+ */
43
+
44
+ import type {
45
+ AuditInput,
46
+ IamRpc,
47
+ IssueJwtInput,
48
+ IssueJwtResult,
49
+ IssueKeyInput,
50
+ IssueKeyResult,
51
+ Jwks,
52
+ ListPrincipalsInput,
53
+ ListPrincipalsResult,
54
+ MintFromKeyInput,
55
+ MintFromKeyResult,
56
+ MintTokenInput,
57
+ MintTokenResult,
58
+ PrincipalListItem,
59
+ PrincipalType,
60
+ PublicJwk,
61
+ RevokeKeyInput,
62
+ RevokeKeyResult,
63
+ } from '@fabrika/auth-core'
64
+ import { booleanField, numberField, prop, stringField } from './json'
65
+
66
+ /** Default per-call timeout. A hung IAM must fail the request, not hold a connection open forever. */
67
+ const DEFAULT_TIMEOUT_MS = 5_000
68
+
69
+ /**
70
+ * Just the call shape this client uses. Narrower than `typeof fetch` on purpose: the global carries
71
+ * runtime-specific extras (Bun's `preconnect`) that a test double has no business implementing.
72
+ */
73
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
74
+
75
+ export interface HttpIamRpcOptions {
76
+ /** The IAM service origin, e.g. `http://iam:3000` on a private network (trailing slash tolerated). */
77
+ origin: string
78
+ /** The shared secret gating `/rpc/*` (`FABRIKA_IAM_RPC_KEY` on the IAM side). NEVER logged. */
79
+ key: string
80
+ /** Per-call timeout in milliseconds. */
81
+ timeoutMs?: number
82
+ /** Injectable for tests; defaults to the global `fetch`. */
83
+ fetch?: FetchLike
84
+ }
85
+
86
+ /**
87
+ * IAM could not be consulted — network failure, timeout, a non-2xx status, or a body that does not
88
+ * match the contract. Distinct from a decided denial, which is a normal result.
89
+ *
90
+ * Carries NO cause and no response body: a `fetch` error stringifies the request URL, and a body can
91
+ * quote a credential that was on the way in.
92
+ */
93
+ export class IamTransportError extends Error {
94
+ constructor(readonly method: string) {
95
+ super(`iam unavailable: ${method}`)
96
+ this.name = 'IamTransportError'
97
+ }
98
+ }
99
+
100
+ export class HttpIamRpc implements IamRpc {
101
+ private readonly origin: string
102
+ private readonly key: string
103
+ private readonly timeoutMs: number
104
+ private readonly fetchImpl: FetchLike
105
+
106
+ constructor(options: HttpIamRpcOptions) {
107
+ this.origin = options.origin.replace(/\/+$/, '')
108
+ this.key = options.key
109
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
110
+ this.fetchImpl = options.fetch ?? globalThis.fetch
111
+ }
112
+
113
+ async mintToken(input: MintTokenInput): Promise<MintTokenResult> {
114
+ const body = await this.call('mintToken', input)
115
+ const failure = readFailure(body)
116
+ if (failure !== null) {
117
+ return { ok: false, reason: oneOf(failure, ['no_session', 'invalid_session', 'unknown_principal', 'disabled'], 'invalid_session') }
118
+ }
119
+ return readMintSuccess(body, 'mintToken')
120
+ }
121
+
122
+ async mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult> {
123
+ const body = await this.call('mintFromKey', input)
124
+ const failure = readFailure(body)
125
+ if (failure !== null) {
126
+ return { ok: false, reason: oneOf(failure, ['invalid_key', 'unknown_principal', 'disabled'], 'invalid_key') }
127
+ }
128
+ return readMintSuccess(body, 'mintFromKey')
129
+ }
130
+
131
+ async issueKey(input: IssueKeyInput): Promise<IssueKeyResult> {
132
+ const body = await this.call('issueKey', input)
133
+ const failure = readFailure(body)
134
+ if (failure !== null) {
135
+ return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
136
+ }
137
+ const token = stringField(body, 'token')
138
+ const id = stringField(body, 'id')
139
+ if (token === undefined || id === undefined) {
140
+ throw new IamTransportError('issueKey')
141
+ }
142
+ const principalId = stringField(body, 'principalId')
143
+ return { ok: true, token, id, ...(principalId === undefined ? {} : { principalId }) }
144
+ }
145
+
146
+ async issueJwt(input: IssueJwtInput): Promise<IssueJwtResult> {
147
+ const body = await this.call('issueJwt', input)
148
+ const failure = readFailure(body)
149
+ if (failure !== null) {
150
+ return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
151
+ }
152
+ const token = stringField(body, 'token')
153
+ const expiresAt = numberField(body, 'expiresAt')
154
+ const id = stringField(body, 'id')
155
+ if (token === undefined || expiresAt === undefined || id === undefined) {
156
+ throw new IamTransportError('issueJwt')
157
+ }
158
+ return { ok: true, token, expiresAt, id }
159
+ }
160
+
161
+ async revokeKey(input: RevokeKeyInput): Promise<RevokeKeyResult> {
162
+ const body = await this.call('revokeKey', input)
163
+ const failure = readFailure(body)
164
+ if (failure !== null) {
165
+ return { ok: false, reason: oneOf(failure, [...ISSUE_REASONS, 'not_found'], 'not_allowed') }
166
+ }
167
+ const revoked = booleanField(body, 'revoked')
168
+ if (revoked === undefined) {
169
+ throw new IamTransportError('revokeKey')
170
+ }
171
+ return { ok: true, revoked }
172
+ }
173
+
174
+ async listPrincipals(input: ListPrincipalsInput): Promise<ListPrincipalsResult> {
175
+ const body = await this.call('listPrincipals', input)
176
+ const failure = readFailure(body)
177
+ if (failure !== null) {
178
+ return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
179
+ }
180
+ const raw = prop(body, 'principals')
181
+ if (!Array.isArray(raw)) {
182
+ throw new IamTransportError('listPrincipals')
183
+ }
184
+ const principals: PrincipalListItem[] = []
185
+ for (const item of raw) {
186
+ const id = stringField(item, 'id')
187
+ const label = stringField(item, 'label')
188
+ const type = stringField(item, 'type')
189
+ if (id === undefined || label === undefined || type === undefined) {
190
+ throw new IamTransportError('listPrincipals')
191
+ }
192
+ const email = prop(item, 'email')
193
+ principals.push({
194
+ id,
195
+ label,
196
+ // Anything but the literal 'service' is a user — the same narrowing the binding transport does.
197
+ type: principalType(type),
198
+ email: typeof email === 'string' ? email : null,
199
+ disabled: booleanField(item, 'disabled') ?? false,
200
+ })
201
+ }
202
+ return { ok: true, principals }
203
+ }
204
+
205
+ async getJwks(): Promise<Jwks> {
206
+ // `getJwks` takes no input, so an empty object body is the natural call (the server tolerates a
207
+ // wholly empty body too). It goes through `/rpc/*` like every other method rather than the public
208
+ // `/.well-known/jwks.json`, so ONE key and ONE surface serve the whole contract.
209
+ const body = await this.call('getJwks', {})
210
+ const rawKeys = prop(body, 'keys')
211
+ if (!Array.isArray(rawKeys)) {
212
+ throw new IamTransportError('getJwks')
213
+ }
214
+ const keys: PublicJwk[] = []
215
+ for (const raw of rawKeys) {
216
+ const kty = stringField(raw, 'kty')
217
+ if (kty === undefined) {
218
+ throw new IamTransportError('getJwks')
219
+ }
220
+ const key: PublicJwk = { kty }
221
+ for (const field of ['crv', 'x', 'y', 'kid', 'alg', 'use'] as const) {
222
+ const value = stringField(raw, field)
223
+ if (value !== undefined) {
224
+ key[field] = value
225
+ }
226
+ }
227
+ keys.push(key)
228
+ }
229
+ return { keys }
230
+ }
231
+
232
+ async audit(event: AuditInput): Promise<void> {
233
+ // 204, no body — `audit` returns void over the binding, so there is nothing to decode. A transport
234
+ // failure still throws: an audit row that silently never happened is the one loss an audit trail
235
+ // cannot absorb.
236
+ await this.send('audit', event)
237
+ }
238
+
239
+ /** POST one method and return its decoded JSON body. Throws `IamTransportError` on any transport fault. */
240
+ private async call(method: string, input: unknown): Promise<unknown> {
241
+ const response = await this.send(method, input)
242
+ const body: unknown = await response.json().catch(() => MALFORMED)
243
+ if (body === MALFORMED) {
244
+ throw new IamTransportError(method)
245
+ }
246
+ return body
247
+ }
248
+
249
+ private async send(method: string, input: unknown): Promise<Response> {
250
+ let response: Response
251
+ try {
252
+ response = await this.fetchImpl(`${this.origin}/rpc/${method}`, {
253
+ method: 'POST',
254
+ headers: {
255
+ 'content-type': 'application/json',
256
+ accept: 'application/json',
257
+ authorization: `Bearer ${this.key}`,
258
+ },
259
+ body: JSON.stringify(input),
260
+ signal: AbortSignal.timeout(this.timeoutMs),
261
+ })
262
+ } catch {
263
+ // Swallow the cause deliberately: a fetch error stringifies the request URL and, on some
264
+ // runtimes, request detail. Only the method name is ever surfaced.
265
+ throw new IamTransportError(method)
266
+ }
267
+ if (!response.ok) {
268
+ throw new IamTransportError(method)
269
+ }
270
+ return response
271
+ }
272
+ }
273
+
274
+ /** The five delegation-path failure reasons `issueKey` / `issueJwt` / `listPrincipals` share. */
275
+ const ISSUE_REASONS = ['missing_token', 'invalid_token', 'unknown_principal', 'disabled', 'not_allowed'] as const
276
+
277
+ /** Distinguishes "the body was not JSON" from a body that decoded to something the readers reject. */
278
+ const MALFORMED = Symbol('malformed')
279
+
280
+ /**
281
+ * The `reason` of a decided negative, or null when the body is an `ok: true` result. A body that is
282
+ * neither (no `ok`, or `ok: false` with no reason) is a contract violation, not a denial — the caller
283
+ * then fails its own success decode and raises `IamTransportError`, which is the fail-closed answer.
284
+ */
285
+ function readFailure(body: unknown): string | null {
286
+ if (prop(body, 'ok') !== false) {
287
+ return null
288
+ }
289
+ return stringField(body, 'reason') ?? ''
290
+ }
291
+
292
+ /** The `{ ok: true, token, expiresAt }` half both mint methods share. */
293
+ function readMintSuccess(body: unknown, method: string): { ok: true; token: string; expiresAt: number } {
294
+ const token = stringField(body, 'token')
295
+ const expiresAt = numberField(body, 'expiresAt')
296
+ if (token === undefined || token === '' || expiresAt === undefined) {
297
+ throw new IamTransportError(method)
298
+ }
299
+ return { ok: true, token, expiresAt }
300
+ }
301
+
302
+ /**
303
+ * Narrow a wire `reason` to the declared union without a cast. An unrecognised reason degrades to the
304
+ * given fallback, which is ALWAYS a deny reason — an IAM that starts speaking a dialect this client
305
+ * does not know must not be able to talk it into an allow.
306
+ */
307
+ function oneOf<T extends string>(value: string, allowed: readonly T[], fallback: T): T {
308
+ for (const candidate of allowed) {
309
+ if (candidate === value) {
310
+ return candidate
311
+ }
312
+ }
313
+ return fallback
314
+ }
315
+
316
+ function principalType(value: string): PrincipalType {
317
+ return value === 'service' ? 'service' : 'user'
318
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Resolve the three-state result from `scopedTo(action, dimension)` into a single value the
3
+ * app controls. This is the SINGLE place the empty-`IN ()` SQL trap is handled: `none`
4
+ * short-circuits without ever emitting `WHERE col IN ()`.
5
+ *
6
+ * - `null` → `all()` — unrestricted (admin / global grant): no filter
7
+ * - `[]` → `none()` — no access: empty result, DO NOT query
8
+ * - non-empty → `some(values)` — filter to these scope values (`WHERE col IN (...)`)
9
+ *
10
+ * The `values` are the opaque, app-owned scope values for that dimension (the app keys its
11
+ * rows by them). The app supplies what all/some/none mean for its own query; filtering must
12
+ * happen at the data layer, never by loading everything and filtering in memory.
13
+ */
14
+ export function applyScope<T>(
15
+ scope: string[] | null,
16
+ branches: { all: () => T; some: (values: string[]) => T; none: () => T },
17
+ ): T {
18
+ if (scope === null) {
19
+ return branches.all()
20
+ }
21
+ if (scope.length === 0) {
22
+ return branches.none()
23
+ }
24
+ return branches.some(scope)
25
+ }