@fabrika/proxy-core 0.2.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/proxy-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,11 @@
19
19
  "bun": "./src/index.ts",
20
20
  "types": "./src/index.ts",
21
21
  "default": "./src/index.ts"
22
+ },
23
+ "./manifest": {
24
+ "bun": "./src/manifest.ts",
25
+ "types": "./src/manifest.ts",
26
+ "default": "./src/manifest.ts"
22
27
  }
23
28
  },
24
29
  "scripts": {
@@ -26,9 +31,10 @@
26
31
  "test": "bun test"
27
32
  },
28
33
  "dependencies": {
29
- "@fabrika/auth-core": "^0.2.0",
30
- "@fabrika/jwks": "^0.2.0",
31
- "@fabrika/proxy-contract": "^0.2.0",
32
- "jose": "^5.9.0"
34
+ "zod": "^3.25.76",
35
+ "@fabrika/auth-core": "^0.3.2",
36
+ "@fabrika/jwks": "^0.3.2",
37
+ "jose": "^5.9.0",
38
+ "@fabrika/platform": "^0.3.2"
33
39
  }
34
40
  }
package/src/authorize.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The proxy authorization core ([ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md)).
2
+ * The proxy authorization core ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
3
3
  *
4
4
  * Same four steps, same order, same fail-closed posture:
5
5
  * 1. match the request path against the app's ordered gate rules;
6
- * 2. resolve the credential the matched rule requires (cookie session, `px_` key, passthrough JWT);
6
+ * 2. resolve the credential the matched rule requires (cookie session or `px_` key);
7
7
  * 3. exchange it with IAM once, cache the result, and thereafter verify LOCALLY against the JWKS;
8
8
  * 4. deny unless a rule was satisfied.
9
9
  *
@@ -17,12 +17,12 @@
17
17
  */
18
18
 
19
19
  import { API_KEY_PREFIX, AUTH_CODE_PARAM, AUTH_HANDOFF_STATE_PARAM, SESSION_COOKIE, TOKEN_REFRESH_SKEW_SECONDS } from '@fabrika/auth-core'
20
- import type { ProxyApp } from '@fabrika/proxy-contract'
21
20
  import { cacheKey, type TokenCache } from './cache'
22
21
  import { LOGIN_REDIRECT_PARAM } from './constants'
23
22
  import { applicableGates, type CompiledGate, readCookie, readServiceCredential } from './gates'
24
23
  import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
25
24
  import { type IamGateway, IamUnavailableError } from './iam'
25
+ import type { ProxyApp } from './manifest'
26
26
  import { TokenVerifier } from './verifier'
27
27
 
28
28
  /** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
@@ -31,7 +31,7 @@ export type DenyReason =
31
31
  | 'no_rule'
32
32
  /** Every matching rule was a `service` rule and no credential was presented. */
33
33
  | 'no_credential'
34
- /** A `px_` key or passthrough JWT was presented and is not valid. */
34
+ /** A service credential was presented and is not a valid `px_` key. */
35
35
  | 'invalid_key'
36
36
  | 'no_session'
37
37
  | 'invalid_session'
@@ -63,7 +63,7 @@ export type Decision =
63
63
  */
64
64
  | { outcome: 'login'; status: 302 | 401; location: string; reason: DenyReason; handoff: HandoffAttempt }
65
65
  /**
66
- * A handoff code was redeemed (ADR-0021). The only outcome that writes the long-lived app
66
+ * A handoff code was redeemed (ADR-0022). The only outcome that writes the long-lived app
67
67
  * session: `session` is host-only on this app's host, and `location` is where IAM said the browser
68
68
  * was going — read from the code server-side, never from the request.
69
69
  */
@@ -189,19 +189,13 @@ export class Authorizer {
189
189
  }
190
190
 
191
191
  /**
192
- * A `service`-rule bearer. A `px_` opaque key is exchanged via `mintFromKey` (cached, so the hot
193
- * path is a local verify); anything else is treated as an already-signed passthrough token and
194
- * verified locally with no IAM call. A present-but-invalid credential fails closed — no fall-through.
192
+ * A `service`-rule bearer. Only a `px_` opaque key satisfies it: it is exchanged via `mintFromKey`
193
+ * (cached, so the hot path is a local verify). Anything else is refused with no IAM call. A
194
+ * present-but-invalid credential fails closed — no fall-through.
195
195
  */
196
196
  private async authorizeBearer(app: ProxyApp, bearer: string, requestId: string): Promise<Decision> {
197
197
  if (!bearer.startsWith(API_KEY_PREFIX)) {
198
- const verified = await this.verifier.verify(bearer, app.id)
199
- if (!verified.ok) {
200
- return verified.reason === 'unavailable'
201
- ? { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
202
- : { outcome: 'deny', status: 401, reason: 'invalid_key' }
203
- }
204
- return { outcome: 'allow', gate: 'service', token: bearer, subject: verified.claims.sub }
198
+ return { outcome: 'deny', status: 401, reason: 'invalid_key' }
205
199
  }
206
200
 
207
201
  const key = cacheKey(app.id, 'key', bearer)
@@ -305,7 +299,7 @@ export class Authorizer {
305
299
  }
306
300
 
307
301
  /**
308
- * Redeem a handoff code (ADR-0021). Called for the reserved callback path only, BEFORE gate
302
+ * Redeem a handoff code (ADR-0022). Called for the reserved callback path only, BEFORE gate
309
303
  * matching — the callback is how a browser becomes able to satisfy a gate, so it cannot be behind
310
304
  * one.
311
305
  *
@@ -387,7 +381,7 @@ export class Authorizer {
387
381
  * A redirect is not a neutral way to say "sign in". A cross-origin bounce to IAM is something a
388
382
  * page's `fetch` cannot follow, so the console sees an opaque network failure instead of a sign-in;
389
383
  * and a 302 destroys the request it answers, turning an in-flight POST into a bodyless GET — the same
390
- * argument ADR-0021 makes for handing over a session rather than a bare token.
384
+ * argument ADR-0022 makes for handing over a session rather than a bare token.
391
385
  *
392
386
  * **`Sec-Fetch-Mode` is the signal, not `Accept`.** It is written by the browser and not by the page:
393
387
  * `Sec-` is a forbidden header PREFIX, so `fetch`/XHR cannot set it, and only a document or frame
package/src/cache.ts CHANGED
Binary file
package/src/constants.ts CHANGED
@@ -30,7 +30,7 @@ export const DEFAULT_HEALTH_PATH = '/healthz'
30
30
  export const APP_QUERY_PARAM = 'app'
31
31
 
32
32
  /**
33
- * Query parameter IAM's `/auth/login` takes the return URL in (ADR-0021). The proxy writes it on the
33
+ * Query parameter IAM's `/auth/login` takes the return URL in (ADR-0022). The proxy writes it on the
34
34
  * login bounce and redacts it out of the access log, where it would re-emit — percent-encoded — a
35
35
  * credential that was just stripped from the logged URI.
36
36
  */
package/src/gates.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Credential extraction for the gate evaluator relocated out of the app's process
3
- * ([ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md)).
3
+ * ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
4
4
  *
5
5
  * The semantics are `AppGates`', verbatim (see `@fabrika/auth-core`'s `types.ts`):
6
6
  * - array order IS the precedence;
package/src/iam.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `IamGateway` is a STRUCTURAL SUBSET of `IamRpc`, so on Cloudflare the thin Worker passes its
5
5
  * `env.IAM` service binding straight in with no adapter. On Zerops there is no binding, so
6
- * `HttpIamGateway` speaks HTTP to the global IAM service — the public hop ADR-0007 calls cold-path
6
+ * `HttpIamGateway` speaks HTTP to the global IAM service — the public hop ADR-0022 calls cold-path
7
7
  * only: everything after the first exchange is a local JWKS verification against the cached token.
8
8
  *
9
9
  * FAIL-CLOSED CONTRACT: an unreachable, slow or incoherent IAM raises `IamUnavailableError`. A
@@ -19,15 +19,25 @@ import type {
19
19
  MintFromKeyResult,
20
20
  MintTokenInput,
21
21
  MintTokenResult,
22
- PublicJwk,
23
22
  } from '@fabrika/auth-core'
23
+ import { parseJwks } from '@fabrika/jwks'
24
+ import { z } from 'zod'
24
25
  import { DEFAULT_IAM_TIMEOUT_MS } from './constants'
25
- import { numberField, prop, stringField } from './json'
26
+
27
+ const denialSchema = z.object({ ok: z.literal(false), reason: z.string() })
28
+ const mintResultSchema = z.discriminatedUnion('ok', [
29
+ z.object({ ok: z.literal(true), token: z.string().min(1), expiresAt: z.number().finite() }),
30
+ denialSchema,
31
+ ])
32
+ const exchangeResultSchema = z.discriminatedUnion('ok', [
33
+ z.object({ ok: z.literal(true), session: z.string().min(1), returnUrl: z.string().min(1), expiresAt: z.number().finite() }),
34
+ denialSchema,
35
+ ])
26
36
 
27
37
  /**
28
38
  * What the proxy needs of IAM. IAM's entrypoints satisfy it structurally — the CF binding drops
29
39
  * straight in with no adapter. Three of the four calls are `IamRpc`; `exchangeAuthCode` is
30
- * deliberately NOT (`IamHandoffRpc`, ADR-0021), because only the proxy may turn a handoff code into
40
+ * deliberately NOT (`IamHandoffRpc`, ADR-0022), because only the proxy may turn a handoff code into
31
41
  * a session and `IamRpc` is held by every SDK consumer.
32
42
  */
33
43
  export interface IamGateway {
@@ -78,11 +88,11 @@ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
78
88
  *
79
89
  * POST {origin}/auth/mint/session {app, session, requestId} -> MintTokenResult
80
90
  * POST {origin}/auth/mint/key {app, key, requestId} -> MintFromKeyResult
81
- * POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-0021)
91
+ * POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-0022)
82
92
  * GET {origin}/.well-known/jwks.json -> Jwks (public)
83
93
  *
84
94
  * The three POST endpoints are the `/auth/mint/*` PROXY surface, gated by IAM's `proxyKey` — the
85
- * least-privilege split ADR-0007 requires, kept apart from the management `/rpc/*` surface. They
95
+ * least-privilege split ADR-0022 requires, kept apart from the management `/rpc/*` surface. They
86
96
  * answer **200 on any decided outcome, including a denial**: `{ ok: false, reason }` is "IAM answered:
87
97
  * no", a non-200 is "IAM could not be consulted". Both deny; only the second is an incident.
88
98
  *
@@ -103,10 +113,11 @@ export class HttpIamGateway implements IamGateway {
103
113
  }
104
114
 
105
115
  async mintToken(input: MintTokenInput): Promise<MintTokenResult> {
106
- const parsed = parseMintResult(await this.post('/auth/mint/session', input, 'mintToken'))
107
- if (parsed === null) {
116
+ const result = mintResultSchema.safeParse(await this.post('/auth/mint/session', input, 'mintToken'))
117
+ if (!result.success) {
108
118
  throw new IamUnavailableError('mintToken')
109
119
  }
120
+ const parsed = result.data
110
121
  if (parsed.ok) {
111
122
  return parsed
112
123
  }
@@ -114,10 +125,11 @@ export class HttpIamGateway implements IamGateway {
114
125
  }
115
126
 
116
127
  async mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult> {
117
- const parsed = parseMintResult(await this.post('/auth/mint/key', input, 'mintFromKey'))
118
- if (parsed === null) {
128
+ const result = mintResultSchema.safeParse(await this.post('/auth/mint/key', input, 'mintFromKey'))
129
+ if (!result.success) {
119
130
  throw new IamUnavailableError('mintFromKey')
120
131
  }
132
+ const parsed = result.data
121
133
  if (parsed.ok) {
122
134
  return parsed
123
135
  }
@@ -125,27 +137,18 @@ export class HttpIamGateway implements IamGateway {
125
137
  }
126
138
 
127
139
  async exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult> {
128
- const body = await this.post('/auth/mint/exchange', input, 'exchangeAuthCode')
129
- const ok = prop(body, 'ok')
130
- if (ok === true) {
131
- const session = stringField(body, 'session')
132
- const returnUrl = stringField(body, 'returnUrl')
133
- const expiresAt = numberField(body, 'expiresAt')
134
- if (session === undefined || session === '' || returnUrl === undefined || returnUrl === '' || expiresAt === undefined) {
135
- throw new IamUnavailableError('exchangeAuthCode')
136
- }
137
- return { ok: true, session, returnUrl, expiresAt }
138
- }
139
- if (ok !== false) {
140
- throw new IamUnavailableError('exchangeAuthCode')
141
- }
142
- const reason = stringField(body, 'reason')
143
- if (reason === undefined) {
140
+ const result = exchangeResultSchema.safeParse(await this.post('/auth/mint/exchange', input, 'exchangeAuthCode'))
141
+ if (!result.success) {
144
142
  throw new IamUnavailableError('exchangeAuthCode')
145
143
  }
144
+ if (result.data.ok) return result.data
146
145
  return {
147
146
  ok: false,
148
- reason: reasonOf(reason, ['invalid_code', 'expired_code', 'wrong_app', 'invalid_verifier', 'unknown_principal', 'disabled'], 'invalid_code'),
147
+ reason: reasonOf(
148
+ result.data.reason,
149
+ ['invalid_code', 'expired_code', 'wrong_app', 'invalid_verifier', 'unknown_principal', 'disabled'],
150
+ 'invalid_code',
151
+ ),
149
152
  }
150
153
  }
151
154
 
@@ -192,26 +195,6 @@ export class HttpIamGateway implements IamGateway {
192
195
  }
193
196
  }
194
197
 
195
- /** The shared `{ok:true,token,expiresAt} | {ok:false,reason}` wire shape, read structurally. */
196
- type ParsedMint = { ok: true; token: string; expiresAt: number } | { ok: false; reason: string }
197
-
198
- function parseMintResult(body: unknown): ParsedMint | null {
199
- const ok = prop(body, 'ok')
200
- if (ok === true) {
201
- const token = stringField(body, 'token')
202
- const expiresAt = numberField(body, 'expiresAt')
203
- if (token === undefined || token === '' || expiresAt === undefined) {
204
- return null
205
- }
206
- return { ok: true, token, expiresAt }
207
- }
208
- if (ok === false) {
209
- const reason = stringField(body, 'reason')
210
- return reason === undefined ? null : { ok: false, reason }
211
- }
212
- return null
213
- }
214
-
215
198
  /**
216
199
  * Narrow a wire `reason` string to the declared union without a cast. An unrecognised reason degrades
217
200
  * to the given fallback, which is always a DENY reason — an IAM that starts speaking a dialect we do
@@ -225,33 +208,3 @@ function reasonOf<T extends string>(value: string, allowed: readonly T[], fallba
225
208
  }
226
209
  return fallback
227
210
  }
228
-
229
- /**
230
- * Read a key set, rejecting one we cannot fully represent. `PublicJwk` carries only the EC members,
231
- * so a key of any other type would be copied into a shape missing the material that verifies it — a
232
- * silently short key set, which reads as "this token is invalid" (a client problem) instead of "we
233
- * could not check" (an incident). IAM signs ES256 only, so anything else means we are not reading the
234
- * key set we think we are: refuse the whole fetch and let the caller raise `IamUnavailableError`.
235
- */
236
- function parseJwks(body: unknown): Jwks | null {
237
- const rawKeys = prop(body, 'keys')
238
- if (!Array.isArray(rawKeys)) {
239
- return null
240
- }
241
- const keys: PublicJwk[] = []
242
- for (const raw of rawKeys) {
243
- const kty = stringField(raw, 'kty')
244
- if (kty !== 'EC') {
245
- return null
246
- }
247
- const key: PublicJwk = { kty }
248
- for (const field of ['crv', 'x', 'y', 'kid', 'alg', 'use'] as const) {
249
- const value = stringField(raw, field)
250
- if (value !== undefined) {
251
- key[field] = value
252
- }
253
- }
254
- keys.push(key)
255
- }
256
- return { keys }
257
- }
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // The deployment that fronts it on Zerops — the Caddy configuration and the compiled binary — is
9
9
  // `@fabrika/proxy`, which is private because it is an artifact rather than an API. The manifest wire
10
- // contract and its strict parser are `@fabrika/proxy-contract`; the gate matcher and the token claims
10
+ // contract and its strict parser are the `./manifest` subpath; the gate matcher and the token claims
11
11
  // are `@fabrika/auth-core`.
12
12
 
13
13
  export { Authorizer } from './authorize'
@@ -0,0 +1,97 @@
1
+ import type { AppGates, CredentialLocation, GateRule } from '@fabrika/auth-core'
2
+ import { z } from 'zod'
3
+
4
+ export const FABRIKA_PROXY_MANIFEST_JSON = 'FABRIKA_PROXY_MANIFEST_JSON'
5
+
6
+ export interface ProxyApp {
7
+ /** The IAM app id, used as the minted token audience. */
8
+ id: string
9
+ /** Literal hostnames, lower-cased on parse; never patterns or host:port pairs. */
10
+ hosts: string[]
11
+ /** Private upstream dial address, for example `app:3000`. */
12
+ upstream: string
13
+ /** Ordered rules; evaluated only by auth-core in the proxy. */
14
+ gates: AppGates
15
+ /** Browser-facing scheme used for login and handoff URLs, defaulting to https. */
16
+ scheme: 'http' | 'https'
17
+ }
18
+
19
+ export interface ProxyManifest {
20
+ apps: ProxyApp[]
21
+ }
22
+
23
+ const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
24
+
25
+ /** Literal LDH hostnames only: Caddy reads wildcard labels and placeholders as routing patterns. */
26
+ export function isProxyHost(host: string): boolean {
27
+ return host.length > 0 && host.length <= 253 && host.split('.').every((label) => label.length <= 63 && HOST_LABEL.test(label))
28
+ }
29
+
30
+ export const proxyCredentialLocationSchema = z.object({
31
+ in: z.enum(['header', 'query', 'cookie']),
32
+ name: z.string().min(1),
33
+ }) satisfies z.ZodType<CredentialLocation, z.ZodTypeDef, unknown>
34
+
35
+ const gatePathSchema = z.string().startsWith('/')
36
+ /** A gate rule's fields before normalization; a producer adds its own refinements on top. */
37
+ export const proxyGateRuleFieldsSchema = z.discriminatedUnion('kind', [
38
+ z.object({ path: gatePathSchema, kind: z.literal('public') }),
39
+ z.object({ path: gatePathSchema, kind: z.literal('human') }),
40
+ z.object({ path: gatePathSchema, kind: z.literal('service'), credential: proxyCredentialLocationSchema.nullish() }),
41
+ ])
42
+
43
+ /** Drop an absent service credential, so a rule without one carries no `credential` key at all. */
44
+ export const normalizeGateRule = (rule: z.infer<typeof proxyGateRuleFieldsSchema>): GateRule => {
45
+ if (rule.kind !== 'service') return rule
46
+ return { path: rule.path, kind: rule.kind, ...(rule.credential == null ? {} : { credential: rule.credential }) }
47
+ }
48
+
49
+ export const proxyGateRuleSchema = proxyGateRuleFieldsSchema.transform(normalizeGateRule) satisfies z.ZodType<GateRule, z.ZodTypeDef, unknown>
50
+
51
+ export const proxyAppGatesSchema = z.object({
52
+ rules: z.array(proxyGateRuleSchema),
53
+ }) satisfies z.ZodType<AppGates, z.ZodTypeDef, unknown>
54
+
55
+ export const proxyAppSchema = z.object({
56
+ id: z.string().min(1),
57
+ hosts: z.array(z.string().toLowerCase().refine(isProxyHost)).min(1),
58
+ upstream: z.string().min(1),
59
+ gates: proxyAppGatesSchema,
60
+ scheme: z.preprocess((value) => typeof value === 'string' ? value : undefined, z.enum(['http', 'https']).default('https')),
61
+ }) satisfies z.ZodType<ProxyApp, z.ZodTypeDef, unknown>
62
+
63
+ /** The first host two apps (or one app twice) claim, or null. A host belongs to exactly one app. */
64
+ export function findDuplicateProxyHost(apps: readonly Pick<ProxyApp, 'hosts'>[]): string | null {
65
+ const hosts = new Set<string>()
66
+ for (const host of apps.flatMap((app) => app.hosts)) {
67
+ if (hosts.has(host)) return host
68
+ hosts.add(host)
69
+ }
70
+ return null
71
+ }
72
+
73
+ export const proxyManifestSchema = z.object({
74
+ apps: z.array(proxyAppSchema),
75
+ }).superRefine(({ apps }, ctx) => {
76
+ if (new Set(apps.map((app) => app.id)).size !== apps.length) ctx.addIssue({ code: 'custom', message: 'duplicate proxy app id' })
77
+ if (findDuplicateProxyHost(apps) !== null) ctx.addIssue({ code: 'custom', message: 'duplicate proxy host' })
78
+ }) satisfies z.ZodType<ProxyManifest, z.ZodTypeDef, unknown>
79
+
80
+ export function parseProxyManifest(value: unknown): ProxyManifest | null {
81
+ const result = proxyManifestSchema.safeParse(value)
82
+ return result.success ? result.data : null
83
+ }
84
+
85
+ export function parseProxyManifestJson(value: string): ProxyManifest | null {
86
+ let parsed: unknown
87
+ try {
88
+ parsed = JSON.parse(value)
89
+ } catch {
90
+ return null
91
+ }
92
+ return parseProxyManifest(parsed)
93
+ }
94
+
95
+ export function encodeProxyManifestJson(manifest: ProxyManifest): string {
96
+ return JSON.stringify(manifest)
97
+ }
package/src/service.ts CHANGED
@@ -21,8 +21,8 @@
21
21
  * back on a 2xx, so the edge is the only thing that mints either.
22
22
  */
23
23
 
24
- import { AUTH_CALLBACK_PATH, HANDOFF_COOKIE_TTL_SECONDS, SESSION_COOKIE, uuidv7 } from '@fabrika/auth-core'
25
- import type { ProxyManifest } from '@fabrika/proxy-contract'
24
+ import { AUTH_CALLBACK_PATH, HANDOFF_COOKIE_TTL_SECONDS, SESSION_COOKIE } from '@fabrika/auth-core'
25
+ import { uuidv7 } from '@fabrika/platform'
26
26
  import { Authorizer, type AuthorizerOptions, type Decision, type DenyReason, type ForwardedRequest, type ResolvedApp } from './authorize'
27
27
  import type { TokenCache } from './cache'
28
28
  import {
@@ -40,6 +40,7 @@ import { compileGates } from './gates'
40
40
  import { type HandoffAttempt, handoffCookieName } from './handoff'
41
41
  import type { IamGateway } from './iam'
42
42
  import { type LogFields, type ProxyLogger, redactPath, silentLogger } from './log'
43
+ import type { ProxyManifest } from './manifest'
43
44
 
44
45
  export interface VerifyServiceConfig {
45
46
  manifest: ProxyManifest
@@ -347,7 +348,7 @@ function readForwardedRequest(request: Request, requestId: string): ForwardedReq
347
348
  * "default app".
348
349
  *
349
350
  * A PINNED app is still cross-checked against the forwarded host. `X-Forwarded-Host` is
350
- * client-controllable once `trusted_proxies` is set (ADR-0010), and the pin is what stops it choosing
351
+ * client-controllable once `trusted_proxies` is set (ADR-0022), and the pin is what stops it choosing
351
352
  * the app — but the same header then builds the login bounce and the handoff callback, so a request
352
353
  * pinned to app A claiming app B's host would send the browser somewhere the manifest never declared.
353
354
  * The authoritative host list is right here; use it.
package/src/json.ts DELETED
@@ -1,44 +0,0 @@
1
- /**
2
- * Tiny structural readers for untrusted JSON (the deployed manifest, an IAM HTTP response body).
3
- * They let the parsers narrow without `as` casts: read a field, check its runtime type, proceed.
4
- * Mirrors `@fabrika/auth-core`'s internal `json.ts`, which is not exported from its entry point.
5
- */
6
-
7
- /** Read a property off an unknown value (undefined when absent / not an object). */
8
- export function prop(value: unknown, key: string): unknown {
9
- if (typeof value !== 'object' || value === null) {
10
- return undefined
11
- }
12
- return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined
13
- }
14
-
15
- export function stringField(value: unknown, key: string): string | undefined {
16
- const v = prop(value, key)
17
- return typeof v === 'string' ? v : undefined
18
- }
19
-
20
- export function numberField(value: unknown, key: string): number | undefined {
21
- const v = prop(value, key)
22
- return typeof v === 'number' && Number.isFinite(v) ? v : undefined
23
- }
24
-
25
- export function booleanField(value: unknown, key: string): boolean | undefined {
26
- const v = prop(value, key)
27
- return typeof v === 'boolean' ? v : undefined
28
- }
29
-
30
- /** An array of strings, or undefined when the field is absent or any element is not a string. */
31
- export function stringArrayField(value: unknown, key: string): string[] | undefined {
32
- const v = prop(value, key)
33
- if (!Array.isArray(v)) {
34
- return undefined
35
- }
36
- const out: string[] = []
37
- for (const item of v) {
38
- if (typeof item !== 'string') {
39
- return undefined
40
- }
41
- out.push(item)
42
- }
43
- return out
44
- }