@fabrika/proxy-core 0.9.0-dev.1 → 0.9.0-dev.3

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.9.0-dev.1",
3
+ "version": "0.9.0-dev.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,22 +12,23 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
- "!src/**/__tests__/**"
15
+ "!src/**/__tests__/**",
16
+ "types"
16
17
  ],
17
18
  "exports": {
18
19
  ".": {
19
20
  "bun": "./src/index.ts",
20
- "types": "./src/index.ts",
21
+ "types": "./types/src/index.d.ts",
21
22
  "default": "./src/index.ts"
22
23
  },
23
24
  "./manifest": {
24
25
  "bun": "./src/manifest.ts",
25
- "types": "./src/manifest.ts",
26
+ "types": "./types/src/manifest.d.ts",
26
27
  "default": "./src/manifest.ts"
27
28
  },
28
29
  "./headers": {
29
30
  "bun": "./src/headers.ts",
30
- "types": "./src/headers.ts",
31
+ "types": "./types/src/headers.d.ts",
31
32
  "default": "./src/headers.ts"
32
33
  }
33
34
  },
@@ -37,9 +38,9 @@
37
38
  },
38
39
  "dependencies": {
39
40
  "zod": "^3.25.76",
40
- "@fabrika/auth-core": "0.9.0-dev.1",
41
- "@fabrika/jwks": "0.9.0-dev.1",
41
+ "@fabrika/auth-core": "0.9.0-dev.3",
42
+ "@fabrika/jwks": "0.9.0-dev.3",
42
43
  "jose": "^5.9.0",
43
- "@fabrika/platform": "0.9.0-dev.1"
44
+ "@fabrika/platform": "0.9.0-dev.3"
44
45
  }
45
46
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The proxy authorization core ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
3
+ *
4
+ * Every entry — a contract application or an installation service — is protected by its endpoint table, in one pass
5
+ * over ONE selected endpoint. A contract application's preflight is decided first, from the endpoint the requested
6
+ * method selects, and never reaches a credential. Then: `planEndpoint` → a `px_` key whose caller kind the endpoint admits, or else the session
7
+ * when `human` may call → the requirement (`satisfies`) → admission → the delegation grant. There is no fall-through
8
+ * to another endpoint. On an installation row that admits `oauth`, a bearer that is not a `px_` key is an OAuth
9
+ * resource token, verified locally. A key or a session is exchanged with IAM once, cached, and thereafter verified
10
+ * LOCALLY against the JWKS.
11
+ *
12
+ * A human miss becomes a bounce to IAM's `/auth/login`, in whichever shape the caller can act on: a
13
+ * 302 for a document navigation, a 401 carrying the same URL in a JSON body for everything else (see
14
+ * `isDocumentNavigation`). Caddy returns a non-2xx auth response — status, headers and body — to the
15
+ * client verbatim, so both reach the caller and neither reaches the app. A request with no session
16
+ * cookie needs no IAM call.
17
+ *
18
+ * NOTHING here returns an allow on an unexpected condition. Every `catch` maps to a deny.
19
+ */
20
+ import { type CompiledEndpointTable } from '@fabrika/auth-core';
21
+ import { type TokenCache } from './cache';
22
+ import { type HandoffAttempt } from './handoff';
23
+ import { type DelegationIssuer, type IamGateway } from './iam';
24
+ import { type ManifestEndpointRow, type OAuthBearerChallenge, type ProxyApp } from './manifest';
25
+ /** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
26
+ export type DenyReason =
27
+ /** The endpoint admits no person, and no credential was presented. */
28
+ 'no_credential'
29
+ /** A bearer was presented and is not a valid `px_` key. */
30
+ | 'invalid_key'
31
+ /** An `oauth` row's bearer is malformed, unverifiable, or minted for another resource or app. */
32
+ | 'invalid_token'
33
+ /** An `oauth` row's bearer verified but lacks a required scope. */
34
+ | 'insufficient_scope' | 'no_session' | 'invalid_session' | 'unknown_principal' | 'disabled'
35
+ /** IAM refused to sign a token above its size bound: the principal holds more than fits in a header. */
36
+ | 'token_too_large'
37
+ /** The entry's table has no endpoint for the method and path. The fail-closed default; no fall-through. */
38
+ | 'no_endpoint'
39
+ /**
40
+ * The credential's caller kind is not one of the endpoint's callers, is no caller kind at all (a job or a webhook
41
+ * key), or is a connector caller off an export path.
42
+ */
43
+ | 'caller_kind'
44
+ /** The token does not satisfy the endpoint's requirement. */
45
+ | 'insufficient_permission'
46
+ /** A session request that is not a `GET` did not come from the app's own origin. */
47
+ | 'cross_site'
48
+ /** The request's `Content-Type` is not one the endpoint admits. */
49
+ | 'content_type'
50
+ /**
51
+ * A preflight for an endpoint the contract does not open to another origin, or from an origin it does not admit; or a
52
+ * multipart form's request from such an origin, which a browser sends without a preflight.
53
+ */
54
+ | 'cross_origin'
55
+ /** IAM refused the delegation grant a membership or scope-creating endpoint needs. */
56
+ | 'delegation_refused'
57
+ /** The `Host` maps to no app in the manifest. */
58
+ | 'no_app'
59
+ /** The forwarded-request headers are missing or malformed — we cannot know what was asked for. */
60
+ | 'bad_forward'
61
+ /** The forwarded path is not canonical (`isCanonicalPath`); refused before any endpoint is planned. */
62
+ | 'non_canonical_path'
63
+ /** IAM could not be consulted, or its keys could not be fetched. We do not know; we refuse. */
64
+ | 'iam_unavailable'
65
+ /** Anything that escaped. Always a deny. */
66
+ | 'internal_error';
67
+ export type Decision = {
68
+ outcome: 'allow';
69
+ /** The verified token to inject upstream. `null` on an anonymous endpoint — no credential, no token. */
70
+ token: string | null;
71
+ /** Principal id (or credential id) for the log line. Never a secret. */
72
+ subject: string | null;
73
+ /** `<id>@<digest>` of the selected endpoint, for `X-Fabrika-Endpoint`. */
74
+ endpoint: string;
75
+ /** The single-use delegation grant for `X-Fabrika-Delegation`, when the endpoint spends one. */
76
+ delegation: string | null;
77
+ /** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
78
+ crossOrigin: string | null;
79
+ /**
80
+ * Whether the request's own `Authorization` reaches the application: on a webhook row that forwards it, unless it
81
+ * carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
82
+ */
83
+ forwardsAuthorization: boolean;
84
+ } | {
85
+ outcome: 'deny';
86
+ status: 400 | 401 | 403 | 503;
87
+ reason: DenyReason;
88
+ clearHandoffState?: string;
89
+ /** Set on an `oauth` row's 401/403: rendered as the RFC 6750 `WWW-Authenticate` challenge. */
90
+ challenge?: OAuthBearerChallenge;
91
+ /** Set when the request's origin is admitted, so the page can read why it was refused. */
92
+ crossOrigin?: string;
93
+ }
94
+ /**
95
+ * A contract application's preflight that the selected endpoint admits (ADR-0022 "Cross-origin endpoints"). The
96
+ * proxy answers it itself; it never reaches the application. `method` is the endpoint's.
97
+ */
98
+ | {
99
+ outcome: 'preflight';
100
+ allowOrigin: string;
101
+ method: string;
102
+ }
103
+ /**
104
+ * A human reached an endpoint `human` may call without a usable session. `location` is where they must go to sign
105
+ * in; `status` is only how they are TOLD — 302 for a document navigation, 401 for anything else,
106
+ * whose body carries the same URL. See `isDocumentNavigation` for why the two differ.
107
+ */
108
+ | {
109
+ outcome: 'login';
110
+ status: 302 | 401;
111
+ location: string;
112
+ reason: DenyReason;
113
+ handoff: HandoffAttempt;
114
+ }
115
+ /**
116
+ * A handoff code was redeemed (ADR-0022). The only outcome that writes the long-lived app
117
+ * session: `session` is host-only on this app's host, and `location` is where IAM said the browser
118
+ * was going — read from the code server-side, never from the request.
119
+ */
120
+ | {
121
+ outcome: 'handoff';
122
+ status: 302;
123
+ location: string;
124
+ session: string;
125
+ expiresAt: number;
126
+ handoffState: string;
127
+ };
128
+ /** The original request, reconstructed from what `forward_auth` forwarded. */
129
+ export interface ForwardedRequest {
130
+ /** Original method; the endpoint table selects by it. */
131
+ method: string;
132
+ /** Original absolute URL; the whole URL is the login return. */
133
+ url: URL;
134
+ /** The forwarded path exactly as the upstream receives it, already checked by `isCanonicalPath`. */
135
+ rawPath: string;
136
+ /** The original request's headers, forwarded verbatim by Caddy. */
137
+ headers: Headers;
138
+ requestId: string;
139
+ }
140
+ /** An entry plus its precompiled endpoint table, built once at startup. */
141
+ export interface ResolvedApp {
142
+ readonly app: ProxyApp;
143
+ readonly table: CompiledEndpointTable<ManifestEndpointRow>;
144
+ }
145
+ export interface AuthorizerOptions {
146
+ iam: IamGateway;
147
+ /** IAM's origin — verifies the token `iss` and is the base for the `/auth/login` bounce. */
148
+ issuer: string;
149
+ /** `null` disables caching entirely. Decisions are identical either way; only the IAM call count changes. */
150
+ cache?: TokenCache | null;
151
+ /** Unix seconds. Injectable for tests. */
152
+ now?: () => number;
153
+ /**
154
+ * Issues the single-use grant a membership or scope-creating endpoint spends at IAM. Unset, such a request is
155
+ * forwarded without one and IAM refuses the membership operation unless the framework obtained a grant itself.
156
+ */
157
+ delegations?: DelegationIssuer | null;
158
+ }
159
+ export declare class Authorizer {
160
+ private readonly options;
161
+ private readonly verifier;
162
+ private readonly cache;
163
+ private readonly now;
164
+ constructor(options: AuthorizerOptions);
165
+ /**
166
+ * Decide one request. NEVER throws and NEVER returns an allow it did not derive from a verified
167
+ * token — the outer `catch` is the last line of the fail-closed guarantee.
168
+ */
169
+ authorize(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision>;
170
+ /**
171
+ * One pass over the endpoint `planEndpoint` selects (ADR-0022 rule 3). An anonymous kind reads no credential and
172
+ * mints no token. A credentialed one takes a `px_` key when one is presented — terminal, whatever its verdict —
173
+ * an OAuth resource token on a row that admits `oauth`, and the session only when `human` may call; then the
174
+ * requirement, then admission.
175
+ */
176
+ private decideEndpoint;
177
+ /**
178
+ * A preflight is decided from the endpoint the REQUESTED method selects, before any credential: it carries none
179
+ * (ADR-0022 "Cross-origin endpoints"). Only a contract application's: an installation row of method `*` serves
180
+ * `OPTIONS` itself.
181
+ */
182
+ private decidePreflight;
183
+ /** The selected row's checks. `crossOrigin` is the admitted origin of a cross-origin request, `*` or none. */
184
+ private decideRow;
185
+ /**
186
+ * A `px_` key wherever the request carries an `Authorization` header, and on a row that admits `oauth` any other
187
+ * credential there as an OAuth resource token; otherwise the session, if `human` may call.
188
+ */
189
+ private endpointCaller;
190
+ /** A fresh grant for every request, never cached: IAM spends it once, for this request id. */
191
+ private obtainDelegation;
192
+ /**
193
+ * An OAuth resource token for one registered resource of this app, carrying every scope required. Verified
194
+ * locally — no IAM call, no cache. RFC 6750 §3.1: a request without a bearer (none, or another scheme) gets a
195
+ * challenge with no error code.
196
+ */
197
+ private verifyOAuthBearer;
198
+ /** Exchange a `px_` key for a verified token, through the cache. A non-`px_` bearer is refused with no IAM call. */
199
+ private exchangeKey;
200
+ /**
201
+ * Resolve a human. Two tiers, cheapest first:
202
+ * 1. a cached token minted earlier from the SAME session — no IAM call;
203
+ * 2. exchange the `px_session` SSO cookie via `mintToken`.
204
+ *
205
+ * Every tier additionally requires `ptype: 'user'`: a session asserts that a resolved USER principal
206
+ * exists. Without the check a broken IAM could turn a service token into a human allow.
207
+ */
208
+ private authorizeSession;
209
+ /**
210
+ * Every IAM call goes through here, so ANY failure — a rejected fetch, a Workers RPC error, a
211
+ * gateway that does not honour the `IamUnavailableError` contract — becomes the same deny. The
212
+ * gateway is an injected interface; the fail-closed guarantee must not depend on its manners.
213
+ */
214
+ private callIam;
215
+ /**
216
+ * Redeem a handoff code (ADR-0022). Called for the reserved callback path only, BEFORE the endpoint
217
+ * is planned — the callback is how a browser obtains a session, so it cannot be behind an endpoint.
218
+ *
219
+ * A failed redemption is an ordinary deny with no cookie. It is deliberately not a fresh bounce to
220
+ * IAM: a code that will not redeem twice would turn a retry into a loop.
221
+ */
222
+ authorizeCallback(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision>;
223
+ private redeem;
224
+ private loginDecision;
225
+ /**
226
+ * Where to send the browser to log in, returning to the originally requested URL afterwards.
227
+ *
228
+ * The scheme comes from the MANIFEST, not from the forwarded request: behind a TLS-terminating
229
+ * balancer the proxy is reached over plain HTTP and `X-Forwarded-Proto` says so, which would send
230
+ * the browser back to an `http://` URL for an origin only served over `https://`.
231
+ */
232
+ private loginUrl;
233
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The minted-token cache — the thing that makes the warm path free.
3
+ *
4
+ * It matters MORE here than it did in the SDK. In-process, a human's freshly minted `px_token` went
5
+ * back to the browser as a `Set-Cookie`, so the browser carried the warm credential itself. Behind
6
+ * `forward_auth` there is no such channel: Caddy copies only the named headers of a 2xx auth response
7
+ * onto the UPSTREAM request and discards the rest, so the proxy cannot hand the browser a cookie on
8
+ * an allowed request. The server-side cache is what replaces it.
9
+ *
10
+ * It is nonetheless BEST-EFFORT. Every entry is re-verified against the JWKS before it is trusted,
11
+ * and `null` (no cache at all) is a supported configuration that changes only how many times IAM is
12
+ * called — never which requests are allowed.
13
+ *
14
+ * The cache is per-process and in-memory ON PURPOSE: ADR-0022 requires the proxy to stay stateless so
15
+ * any instance can serve any request. A shared/persistent cache would be state, and would put minted
16
+ * tokens somewhere they outlive the process.
17
+ */
18
+ export interface CachedToken {
19
+ token: string;
20
+ /** Unix seconds. */
21
+ expiresAt: number;
22
+ }
23
+ export interface TokenCache {
24
+ get(key: string): CachedToken | undefined;
25
+ set(key: string, value: CachedToken): void;
26
+ delete(key: string): void;
27
+ }
28
+ /**
29
+ * A bounded in-memory cache. Insertion-ordered eviction (Map iteration order) keeps it to
30
+ * `maxEntries`, so a flood of distinct credentials cannot grow it without bound.
31
+ *
32
+ * The key is derived from a raw credential (see `cacheKey`). It is never logged, never serialized
33
+ * and never leaves the process.
34
+ */
35
+ export declare class MemoryTokenCache implements TokenCache {
36
+ private readonly maxEntries;
37
+ private readonly entries;
38
+ constructor(maxEntries?: number);
39
+ get(key: string): CachedToken | undefined;
40
+ set(key: string, value: CachedToken): void;
41
+ delete(key: string): void;
42
+ /** Test/introspection helper — the number of live entries. Never exposes keys or tokens. */
43
+ get size(): number;
44
+ }
45
+ /**
46
+ * Namespace a cache key by app and credential kind. Without the app id a proxy fronting two apps
47
+ * could serve app A's token for app B — the `aud` check would still reject it (so it is a correctness
48
+ * non-issue), but it would turn a warm hit into a needless deny.
49
+ */
50
+ export declare function cacheKey(app: string, kind: 'session' | 'key', credential: string): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The wire names the proxy and its Caddy config agree on. Both sides are generated from this file
3
+ * (`caddy.ts` reads the same constants the service does), so a rename cannot desynchronise them.
4
+ */
5
+ export { CLIENT_ADDRESS_HEADER, DELEGATION_HEADER, ENDPOINT_HEADER, PROXY_TOKEN_HEADER, UNTRUSTED_FORWARD_HEADERS } from '@fabrika/auth-core';
6
+ export declare const FORWARDED_METHOD_HEADER = "X-Forwarded-Method";
7
+ export declare const FORWARDED_URI_HEADER = "X-Forwarded-Uri";
8
+ export declare const FORWARDED_HOST_HEADER = "X-Forwarded-Host";
9
+ export declare const FORWARDED_PROTO_HEADER = "X-Forwarded-Proto";
10
+ /** Correlation id, when the edge supplies one; otherwise the proxy generates a UUIDv7. */
11
+ export declare const REQUEST_ID_HEADER = "X-Request-Id";
12
+ /** The auth service's decision endpoint (Caddy's `uri` subdirective). */
13
+ export declare const DEFAULT_VERIFY_PATH = "/verify";
14
+ /**
15
+ * Set on the auth service's 2xx to a preflight it admitted (ADR-0022 "Cross-origin endpoints"). The answer is the
16
+ * preflight's whole answer: the composition returns it to the browser, without the marker, and never dials the app.
17
+ */
18
+ export declare const PREFLIGHT_HEADER = "X-Fabrika-Preflight";
19
+ /**
20
+ * Set on the auth service's 2xx when the request's own `Authorization` goes on to the application: an anonymous webhook
21
+ * row that forwards it. Every composition deletes `Authorization` from an anonymous request without it, and passes the
22
+ * marker on to no one.
23
+ */
24
+ export declare const FORWARD_AUTHORIZATION_HEADER = "X-Fabrika-Forward-Authorization";
25
+ /** Liveness endpoint on the auth service itself — never routed publicly. */
26
+ export declare const DEFAULT_HEALTH_PATH = "/healthz";
27
+ /**
28
+ * Query parameter the generated Caddy route pins the app id with. Explicit beats host-sniffing: the
29
+ * route already knows which app it fronts, so the auth service never has to infer it.
30
+ */
31
+ export declare const APP_QUERY_PARAM = "app";
32
+ /**
33
+ * Query parameter IAM's `/auth/login` takes the return URL in (ADR-0022). The proxy writes it on the
34
+ * login bounce and redacts it out of the access log, where it would re-emit — percent-encoded — a
35
+ * credential that was just stripped from the logged URI.
36
+ */
37
+ export declare const LOGIN_REDIRECT_PARAM = "redirect";
38
+ /** The JWKS cache TTL and the unknown-`kid` refetch cooldown, owned by the verifier both planes share. */
39
+ export { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from '@fabrika/jwks';
40
+ /** Default IAM request timeout. A hung IAM must deny, not hang every request behind it. */
41
+ export declare const DEFAULT_IAM_TIMEOUT_MS = 5000;
@@ -0,0 +1,10 @@
1
+ /** Read the token out of an `Authorization: Bearer <token>` header. Null when absent/non-bearer. */
2
+ export declare function readBearer(header: string | null): string | null;
3
+ /**
4
+ * Whether an `Authorization` value carries a platform key anywhere: the header itself, or the user or password of Basic
5
+ * credentials. Such a value is never forwarded to an application, whatever its row says: a key sent to a webhook URL
6
+ * by mistake must not reach application code.
7
+ */
8
+ export declare function carriesPlatformKey(header: string, keyPrefix: string): boolean;
9
+ /** Read a single cookie value out of a raw `Cookie` header. Null when absent or malformed. */
10
+ export declare function readCookie(header: string | null, name: string): string | null;
@@ -0,0 +1,15 @@
1
+ export interface HandoffAttempt {
2
+ state: string;
3
+ verifier: string;
4
+ challenge: string;
5
+ }
6
+ /** Create the browser-held verifier and the public values IAM carries through the login. */
7
+ export declare function createHandoffAttempt(): Promise<HandoffAttempt>;
8
+ /** S256, matching PKCE: the verifier never rides in a URL or reaches IAM through the browser. */
9
+ export declare function handoffChallenge(verifier: string): Promise<string>;
10
+ /** Return the dynamic `__Host-` cookie name for a validated public state. */
11
+ export declare function handoffCookieName(state: string | null): string | null;
12
+ /** Add the public handoff coordinates to IAM's login URL. */
13
+ export declare function setHandoffParams(url: URL, attempt: HandoffAttempt): void;
14
+ /** Keep application cookies, but never expose a Fabrika bearer credential to the upstream app. */
15
+ export declare function stripFabrikaCookies(headers: Headers): void;
@@ -0,0 +1,112 @@
1
+ import type { CspExtensions, EndpointCache } from '@fabrika/auth-core';
2
+ /** Every powerful feature off. ADR-0022 lists the same names; change both together. */
3
+ export declare const PERMISSIONS_POLICY_FEATURES: readonly string[];
4
+ export type HeaderPolicy = readonly (readonly [name: string, value: string])[];
5
+ /** The named origins a contract adds to seven CSP directives: exact `https://` origins, never patterns. */
6
+ export interface CspOrigins {
7
+ readonly connectSrc: readonly string[];
8
+ readonly imgSrc: readonly string[];
9
+ readonly frameAncestors: readonly string[];
10
+ readonly scriptSrc: readonly string[];
11
+ /** `frame-src 'self' <origins>` is emitted only when it names an origin; without it frames fall back to `default-src 'self'`. */
12
+ readonly frameSrc: readonly string[];
13
+ readonly styleSrc: readonly string[];
14
+ readonly fontSrc: readonly string[];
15
+ /**
16
+ * `worker-src 'self' blob:` is emitted only when it holds `blob:`, its one source (a challenge widget that starts its
17
+ * workers from `Blob` URLs); without it workers fall back to `script-src`. Absent from every manifest without it, so a
18
+ * proxy that does not know the directive still parses those.
19
+ */
20
+ readonly workerSrc?: readonly string[];
21
+ }
22
+ /** The one source a contract's `workerSrc` holds: a worker the page's own script built from a `Blob`. */
23
+ export declare const WORKER_BLOB_SOURCE = "blob:";
24
+ /**
25
+ * An exact origin with a literal hostname: no path, no credentials and no wildcard (`new URL` accepts `*` in a
26
+ * hostname), so it means one site and is safe inside a header value. CSP origins are `https:` only.
27
+ */
28
+ export declare function isPolicyOrigin(value: string, protocols?: readonly string[]): boolean;
29
+ /** The most named origins one directive may carry: the contract build and the manifest parser refuse more. */
30
+ export declare const MAX_CSP_ORIGINS = 16;
31
+ export declare const NO_CSP_ORIGINS: CspOrigins;
32
+ export declare function cspOrigins(csp: CspExtensions): CspOrigins;
33
+ /**
34
+ * The policy for one application: the fixed CSP, extended by the contract's named origins and by the Operations
35
+ * ingest origin the browser reports errors to.
36
+ */
37
+ export declare function headerPolicy(origins: CspOrigins, operationsOrigin: string | null): HeaderPolicy;
38
+ /** The browser-facing scheme of a manifest entry's hosts; behind a TLS-terminating balancer no header can supply it. */
39
+ export type PublicScheme = 'http' | 'https';
40
+ /** One year. No `includeSubDomains` and no `preload`: applications share parent domains. */
41
+ export declare const STRICT_TRANSPORT_SECURITY = "max-age=31536000";
42
+ /**
43
+ * What every answer of an application host carries for its transport: HSTS on an `https` host, nothing on `http` (the
44
+ * local stack). Only the proxy knows the scheme, from the manifest, so the framework's in-process policy has none.
45
+ */
46
+ export declare function transportPolicy(scheme: PublicScheme): HeaderPolicy;
47
+ /**
48
+ * The transport headers the policy owns: an `https` entry's policy sets them, and every other answer loses the
49
+ * application's own, so an `http` host never pins a browser to a scheme it does not serve.
50
+ */
51
+ export declare const TRANSPORT_HEADERS: readonly string[];
52
+ /** The policy of a contract application's manifest entry, its transport header included. */
53
+ export declare function appHeaderPolicy(app: {
54
+ readonly csp: CspOrigins;
55
+ readonly operationsOrigin?: string;
56
+ readonly scheme: PublicScheme;
57
+ }): HeaderPolicy;
58
+ /** The policy of an answer that belongs to no contract application: no named origin, no ingest. */
59
+ export declare const BASE_HEADER_POLICY: HeaderPolicy;
60
+ /** Raster formats a browser only ever renders as an image; every other download, SVG and HTML included, is an attachment. */
61
+ export declare const INLINE_FILE_TYPES: readonly string[];
62
+ /** What every answer of a download endpoint carries in place of the policy, its errors included. */
63
+ export declare const FILE_TREATMENT: HeaderPolicy;
64
+ /** The file treatment of a manifest entry's downloads, its transport header included. */
65
+ export declare function appFileTreatment(app: {
66
+ readonly scheme: PublicScheme;
67
+ }): HeaderPolicy;
68
+ /**
69
+ * Headers an application may not send at all: each names a reporting endpoint the policy never approved, and a
70
+ * report-only CSP reports there whatever the enforced one says (`document-uri`, `blocked-uri`).
71
+ */
72
+ export declare const REMOVED_RESPONSE_HEADERS: readonly string[];
73
+ /** The most origins one environment may list for its cross-origin endpoints: Control and the manifest parser refuse more. */
74
+ export declare const MAX_CROSS_ORIGINS = 16;
75
+ /**
76
+ * An origin an environment may list for its cross-origin endpoints: exact, a literal host, `https:` (or `http:`, which
77
+ * only a local host is served over). `null`, an opaque origin, never matches.
78
+ */
79
+ export declare function isCrossOrigin(value: string): boolean;
80
+ /** What a cross-origin page may send beside the safelisted headers: the key, and the JSON body's type. */
81
+ export declare const CROSS_ORIGIN_REQUEST_HEADERS = "Authorization, Content-Type";
82
+ /** How long a browser may reuse an answered preflight, in seconds. */
83
+ export declare const CROSS_ORIGIN_MAX_AGE = "600";
84
+ /**
85
+ * The headers an answer to an admitted cross-origin request carries: the admitted origin, and `Vary: Origin` unless every
86
+ * origin is admitted. Never `Access-Control-Allow-Credentials`: a session does not cross origins.
87
+ */
88
+ export declare function crossOriginHeaders(allowOrigin: string): HeaderPolicy;
89
+ /** A preflight's answer on top of `crossOriginHeaders`: the endpoint's one method, the two headers, the cache lifetime. */
90
+ export declare function preflightHeaders(allowOrigin: string, method: string): HeaderPolicy;
91
+ /** `crossOriginHeaders` over headers that already carry the answer's own policy; `Vary` is added to, never replaced. */
92
+ export declare function applyCrossOriginHeaders(headers: Headers, policy: HeaderPolicy): void;
93
+ /** `null` and anything but one allowlisted value is an attachment; `Headers` joins repeated fields with `, `, so two never match. */
94
+ export declare function fileDisposition(contentType: string | null): 'inline' | 'attachment';
95
+ /** Whatever the handler or an asset server set for these names is replaced; the removed and transport headers are dropped first. */
96
+ export declare function applyHeaderPolicy(headers: Headers, policy: HeaderPolicy): void;
97
+ /**
98
+ * The file treatment over headers a download already carries: the disposition type follows the `Content-Type`
99
+ * exactly, and the parameters after it (the file name) are kept. The proxy passes `appFileTreatment` of its entry.
100
+ */
101
+ export declare function applyFileTreatment(headers: Headers, treatment?: HeaderPolicy): void;
102
+ /**
103
+ * A cacheable row's successful answer (`http.cache`): its `Cache-Control` replaces the `no-store` of the policy or the
104
+ * file treatment. Every other answer, an error or a redirect included, keeps `no-store`.
105
+ */
106
+ export declare function applyAnswerCache(headers: Headers, row: {
107
+ readonly cache?: EndpointCache;
108
+ }, status: number): void;
109
+ /** The `Cache-Control` of a cacheable row's successful answer: `immutable` is reused for a year. */
110
+ export declare function cacheControlOf(cache: EndpointCache): string;
111
+ /** A copy of `response` whose headers `apply` rewrote; status and body are unchanged. */
112
+ export declare function withResponseHeaders(response: Response, apply: (headers: Headers) => void): Response;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The proxy's view of the IAM service: the three cold-path calls it needs, and nothing else.
3
+ *
4
+ * `IamGateway` is a STRUCTURAL SUBSET of `IamRpc`, so on Cloudflare the thin Worker passes its
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-0022 calls cold-path
7
+ * only: everything after the first exchange is a local JWKS verification against the cached token.
8
+ *
9
+ * FAIL-CLOSED CONTRACT: an unreachable, slow or incoherent IAM raises `IamUnavailableError`. A
10
+ * *decided* negative (`{ ok: false, reason }`) is returned normally. The caller must map the former
11
+ * to a deny too — the distinction only chooses the status, never the outcome.
12
+ */
13
+ import type { ExchangeAuthCodeInput, ExchangeAuthCodeResult, Jwks, MintFromKeyInput, MintFromKeyResult, MintTokenInput, MintTokenResult } from '@fabrika/auth-core';
14
+ /**
15
+ * What the proxy needs of IAM. IAM's entrypoints satisfy it structurally — the CF binding drops
16
+ * straight in with no adapter. Three of the four calls are `IamRpc`; `exchangeAuthCode` is
17
+ * deliberately NOT (`IamHandoffRpc`, ADR-0022), because only the proxy may turn a handoff code into
18
+ * a session and `IamRpc` is held by every SDK consumer.
19
+ */
20
+ export interface IamGateway {
21
+ mintToken(input: MintTokenInput): Promise<MintTokenResult>;
22
+ mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult>;
23
+ exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult>;
24
+ getJwks(): Promise<Jwks>;
25
+ }
26
+ /**
27
+ * Issues the single-use delegation grant a membership or scope-creating endpoint spends (ADR-0022 rule 6). A
28
+ * structural subset of IAM's membership RPC, so its binding drops in like `IamGateway`'s does. `credential` is the
29
+ * token the proxy just minted for this request; the grant is bound to `endpoint` and `requestId`.
30
+ */
31
+ export interface DelegationIssuer {
32
+ requestDelegation(input: DelegationRequest): Promise<{
33
+ ok: true;
34
+ grant: string;
35
+ expiresAt: number;
36
+ } | {
37
+ ok: false;
38
+ reason: string;
39
+ }>;
40
+ }
41
+ export interface DelegationRequest {
42
+ app: string;
43
+ credential: string;
44
+ requestId: string;
45
+ endpoint: string;
46
+ }
47
+ /**
48
+ * IAM could not be consulted — network failure, timeout, non-200, or a body we cannot read. Distinct
49
+ * from a decided denial: we do not know the answer, so the request is denied with 503 rather than
50
+ * being told it is unauthorized.
51
+ *
52
+ * Carries NO cause and no response body: an error object from `fetch` can carry a URL, and a body
53
+ * can carry a token.
54
+ */
55
+ export declare class IamUnavailableError extends Error {
56
+ readonly operation: string;
57
+ constructor(operation: string);
58
+ }
59
+ export interface HttpIamGatewayOptions {
60
+ /** The IAM service origin, e.g. `https://iam.example.com` (trailing slash tolerated). */
61
+ origin: string;
62
+ /** Per-call timeout. A hung IAM must deny, not hang every request behind the proxy. */
63
+ timeoutMs?: number;
64
+ /** Optional `Authorization: Bearer` the proxy authenticates ITSELF to IAM with. */
65
+ key?: string;
66
+ /** Injectable for tests; defaults to the global `fetch`. */
67
+ fetch?: FetchLike;
68
+ }
69
+ /**
70
+ * Just the call shape the gateway 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
+ * HTTP transport for `IamGateway` — the Zerops path, where there is no service binding.
76
+ *
77
+ * This file and `packages/iam/src/rpc-http.ts` are two halves of one contract; neither may move
78
+ * without the other in the SAME commit. A mismatch fails closed (any non-200 becomes
79
+ * `IamUnavailableError` → a 503 deny) but is very hard to read from the outside.
80
+ *
81
+ * POST {origin}/auth/mint/session {app, session, requestId} -> MintTokenResult
82
+ * POST {origin}/auth/mint/key {app, key, requestId} -> MintFromKeyResult
83
+ * POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-0022)
84
+ * POST {origin}/auth/mint/delegation {app, credential, requestId, endpoint} -> a grant or a denial (rule 6)
85
+ * GET {origin}/.well-known/jwks.json -> Jwks (public)
86
+ *
87
+ * The four POST endpoints are the `/auth/mint/*` PROXY surface, authenticated by IAM's `proxyKey` — the
88
+ * least-privilege split ADR-0022 requires, kept apart from the management `/rpc/*` surface. They
89
+ * answer **200 on any decided outcome, including a denial**: `{ ok: false, reason }` is "IAM answered:
90
+ * no", a non-200 is "IAM could not be consulted". Both deny; only the second is an incident.
91
+ *
92
+ * Credentials always ride in the POST body, never in a URL — a URL ends up in access logs and error
93
+ * strings; a body does not.
94
+ */
95
+ export declare class HttpIamGateway implements IamGateway, DelegationIssuer {
96
+ private readonly origin;
97
+ private readonly timeoutMs;
98
+ private readonly key;
99
+ private readonly fetchImpl;
100
+ constructor(options: HttpIamGatewayOptions);
101
+ mintToken(input: MintTokenInput): Promise<MintTokenResult>;
102
+ mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult>;
103
+ exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult>;
104
+ requestDelegation(input: DelegationRequest): Promise<{
105
+ ok: true;
106
+ grant: string;
107
+ expiresAt: number;
108
+ } | {
109
+ ok: false;
110
+ reason: string;
111
+ }>;
112
+ getJwks(): Promise<Jwks>;
113
+ private post;
114
+ private send;
115
+ }
@@ -0,0 +1,16 @@
1
+ export { Authorizer } from './authorize';
2
+ export type { AuthorizerOptions, Decision, DenyReason, ForwardedRequest, ResolvedApp } from './authorize';
3
+ export { cacheKey, MemoryTokenCache } from './cache';
4
+ export type { CachedToken, TokenCache } from './cache';
5
+ export { APP_QUERY_PARAM, CLIENT_ADDRESS_HEADER, DEFAULT_HEALTH_PATH, DEFAULT_IAM_TIMEOUT_MS, DEFAULT_VERIFY_PATH, DELEGATION_HEADER, ENDPOINT_HEADER, FORWARD_AUTHORIZATION_HEADER, FORWARDED_HOST_HEADER, FORWARDED_METHOD_HEADER, FORWARDED_PROTO_HEADER, FORWARDED_URI_HEADER, JWKS_TTL_SECONDS, LOGIN_REDIRECT_PARAM, PREFLIGHT_HEADER, PROXY_TOKEN_HEADER, REQUEST_ID_HEADER, UNTRUSTED_FORWARD_HEADERS, } from './constants';
6
+ export { carriesPlatformKey, readBearer, readCookie } from './credentials';
7
+ export { stripFabrikaCookies } from './handoff';
8
+ export { HttpIamGateway, IamUnavailableError } from './iam';
9
+ export type { DelegationIssuer, DelegationRequest, HttpIamGatewayOptions, IamGateway } from './iam';
10
+ export { consoleLogger, redactPath, redactUrl, silentLogger } from './log';
11
+ export type { LogFields, LogValue, ProxyLogger } from './log';
12
+ export { findOAuthMetadata, OAUTH_METADATA_VERIFY_PATH, oauthMetadataResponse } from './oauth';
13
+ export { createVerifyService } from './service';
14
+ export type { VerifyService, VerifyServiceConfig } from './service';
15
+ export { TokenVerifier } from './verifier';
16
+ export type { VerifyResult } from './verifier';