@fabrika/proxy-core 0.8.1 → 0.9.0-cloudflare.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/proxy-core",
3
- "version": "0.8.1",
3
+ "version": "0.9.0-cloudflare.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,22 +12,33 @@
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
  },
29
+ "./brand": {
30
+ "bun": "./src/brand.ts",
31
+ "types": "./types/src/brand.d.ts",
32
+ "default": "./src/brand.ts"
33
+ },
34
+ "./page-style": {
35
+ "bun": "./src/page-style.ts",
36
+ "types": "./types/src/page-style.d.ts",
37
+ "default": "./src/page-style.ts"
38
+ },
28
39
  "./headers": {
29
40
  "bun": "./src/headers.ts",
30
- "types": "./src/headers.ts",
41
+ "types": "./types/src/headers.d.ts",
31
42
  "default": "./src/headers.ts"
32
43
  }
33
44
  },
@@ -37,9 +48,9 @@
37
48
  },
38
49
  "dependencies": {
39
50
  "zod": "^3.25.76",
40
- "@fabrika/auth-core": "0.8.1",
41
- "@fabrika/jwks": "0.8.1",
51
+ "@fabrika/auth-core": "0.9.0-cloudflare.1",
52
+ "@fabrika/jwks": "0.9.0-cloudflare.1",
42
53
  "jose": "^5.9.0",
43
- "@fabrika/platform": "0.8.1"
54
+ "@fabrika/platform": "0.9.0-cloudflare.1"
44
55
  }
45
56
  }
package/src/authorize.ts CHANGED
@@ -36,9 +36,9 @@ import {
36
36
  type TokenCaller,
37
37
  } from '@fabrika/auth-core'
38
38
  import { cacheKey, type TokenCache } from './cache'
39
- import { LOGIN_REDIRECT_PARAM } from './constants'
39
+ import { LOGIN_REDIRECT_PARAM, SIGNOUT_RELEASE_PATH } from './constants'
40
40
  import { carriesPlatformKey, readBearer, readCookie } from './credentials'
41
- import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
41
+ import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams, unmatchedVerifier } from './handoff'
42
42
  import { type DelegationIssuer, type IamGateway, IamUnavailableError } from './iam'
43
43
  import {
44
44
  isApplicationEntry,
@@ -48,6 +48,7 @@ import {
48
48
  oauthResourceOfRow,
49
49
  type ProxyApp,
50
50
  } from './manifest'
51
+ import { FETCH_MODE_HEADER } from './pages'
51
52
  import { TokenVerifier } from './verifier'
52
53
 
53
54
  /** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
@@ -75,8 +76,10 @@ export type DenyReason =
75
76
  | 'caller_kind'
76
77
  /** The token does not satisfy the endpoint's requirement. */
77
78
  | 'insufficient_permission'
78
- /** A session request that is not a `GET` did not come from the app's own origin. */
79
+ /** A session request that is not a `GET`, or a sign-out, did not come from the app's own origin. */
79
80
  | 'cross_site'
81
+ /** A sign-out's return is absent or not a path on the app's own origin. */
82
+ | 'bad_redirect'
80
83
  /** The request's `Content-Type` is not one the endpoint admits. */
81
84
  | 'content_type'
82
85
  /**
@@ -111,8 +114,8 @@ export type Decision =
111
114
  /** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
112
115
  crossOrigin: string | null
113
116
  /**
114
- * Whether the request's own `Authorization` reaches the application: on a webhook row that forwards it, unless it
115
- * carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
117
+ * Whether the request's own `Authorization` reaches the application: on the ops row, and on a webhook row that
118
+ * forwards it unless it carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
116
119
  */
117
120
  forwardsAuthorization: boolean
118
121
  }
@@ -143,6 +146,11 @@ export type Decision =
143
146
  * was going — read from the code server-side, never from the request.
144
147
  */
145
148
  | { outcome: 'handoff'; status: 302; location: string; session: string; expiresAt: number; handoffState: string }
149
+ /**
150
+ * A same-origin sign-out (ADR-0067 §5): the app session cookie is cleared and the browser goes to IAM's release,
151
+ * which returns it to `location`'s own redirect on this app's origin.
152
+ */
153
+ | { outcome: 'signout'; status: 302; location: string }
146
154
 
147
155
  /** The original request, reconstructed from what `forward_auth` forwarded. */
148
156
  export interface ForwardedRequest {
@@ -178,13 +186,6 @@ export interface AuthorizerOptions {
178
186
  delegations?: DelegationIssuer | null
179
187
  }
180
188
 
181
- /**
182
- * The browser's own description of what issued the request. Read from the FORWARDED headers like
183
- * everything else a decision depends on — verified against caddy 2.10.2, whose `forward_auth`
184
- * subrequest carries the original `Sec-Fetch-*` verbatim.
185
- */
186
- const FETCH_MODE_HEADER = 'Sec-Fetch-Mode'
187
-
188
189
  /** Fetch metadata the browser writes and page script cannot: `same-origin` only for a request from this origin. */
189
190
  const FETCH_SITE_HEADER = 'Sec-Fetch-Site'
190
191
 
@@ -583,21 +584,56 @@ export class Authorizer {
583
584
  }
584
585
  const cookieName = handoffCookieName(state)
585
586
  const verifier = cookieName === null ? null : readCookie(request.headers.get('Cookie'), cookieName)
587
+ // Through `callIam` like every other IAM call, so an unreachable service is a 503 whatever kind
588
+ // of error the injected gateway happened to throw. `authorize`'s outer catch maps the rest.
589
+ const redeem = (presented: string) =>
590
+ this.callIam(
591
+ 'exchangeAuthCode',
592
+ () => this.options.iam.exchangeAuthCode({ app: resolved.app.id, code, verifier: presented, requestId: request.requestId }),
593
+ )
586
594
  if (verifier === null) {
595
+ // This browser did not start the sign-in the code belongs to. The code is spent, so whoever started it cannot
596
+ // redeem it from the address this browser shows (ADR-0067 §3).
597
+ await redeem(unmatchedVerifier())
587
598
  return { outcome: 'deny', status: 403, reason: 'invalid_session', clearHandoffState: state }
588
599
  }
589
- // Through `callIam` like every other IAM call, so an unreachable service is a 503 whatever kind
590
- // of error the injected gateway happened to throw. `authorize`'s outer catch maps the rest.
591
- const result = await this.callIam(
592
- 'exchangeAuthCode',
593
- () => this.options.iam.exchangeAuthCode({ app: resolved.app.id, code, verifier, requestId: request.requestId }),
594
- )
600
+ const result = await redeem(verifier)
595
601
  if (!result.ok) {
596
602
  return { outcome: 'deny', status: 403, reason: 'invalid_session', clearHandoffState: state }
597
603
  }
598
604
  return { outcome: 'handoff', status: 302, location: result.returnUrl, session: result.session, expiresAt: result.expiresAt, handoffState: state }
599
605
  }
600
606
 
607
+ /**
608
+ * Sign the browser out of this host (ADR-0067 §5). Called for the reserved sign-out path only, BEFORE the endpoint
609
+ * is planned, for any entry and any session. It drops this session's cached token, so the next request re-mints
610
+ * from IAM, which has revoked the session by then or still holds it; the cookie itself is cleared by the response.
611
+ *
612
+ * Admission is `Sec-Fetch-Site: same-origin`, absent included: the request changes state, and another page must not
613
+ * sign a person out, as for a session request that is not a `GET` (ADR-0022 rule 3, step 6).
614
+ */
615
+ signOut(resolved: ResolvedApp, request: ForwardedRequest): Decision {
616
+ if (request.method !== 'GET') {
617
+ return { outcome: 'deny', status: 403, reason: 'no_endpoint' }
618
+ }
619
+ if (request.headers.get(FETCH_SITE_HEADER) !== 'same-origin') {
620
+ return { outcome: 'deny', status: 403, reason: 'cross_site' }
621
+ }
622
+ const app = resolved.app
623
+ const target = sameOriginTarget(new URL(`${app.scheme}://${request.url.host}`).origin, request.url.searchParams.get(LOGIN_REDIRECT_PARAM))
624
+ if (target === null) {
625
+ return { outcome: 'deny', status: 400, reason: 'bad_redirect' }
626
+ }
627
+ const session = readCookie(request.headers.get('Cookie'), SESSION_COOKIE)
628
+ if (session !== null) {
629
+ this.cache?.delete(cacheKey(app.id, 'session', session))
630
+ }
631
+ const release = new URL(SIGNOUT_RELEASE_PATH, this.options.issuer.replace(/\/+$/, ''))
632
+ release.searchParams.set('app', app.id)
633
+ release.searchParams.set(LOGIN_REDIRECT_PARAM, target)
634
+ return { outcome: 'signout', status: 302, location: release.toString() }
635
+ }
636
+
601
637
  private async loginDecision(
602
638
  app: ProxyApp,
603
639
  request: ForwardedRequest,
@@ -647,6 +683,23 @@ function admittedOrigin(app: ProxyApp, row: ManifestEndpointRow, origin: string)
647
683
  return form === 'environment' && isApplicationEntry(app) && (app.crossOrigins ?? []).includes(origin) ? origin : null
648
684
  }
649
685
 
686
+ /**
687
+ * The absolute URL of `path` on `origin`, or `null` when `path` is not a path on that origin. A leading `//` or any
688
+ * `\` would make the browser read a host from it, and a control character or a space is never part of a return path.
689
+ */
690
+ function sameOriginTarget(origin: string, path: string | null): string | null {
691
+ if (path === null || !path.startsWith('/') || path.startsWith('//') || path.includes('\\') || [...path].some(isControlOrSpace)) {
692
+ return null
693
+ }
694
+ const target = new URL(path, origin)
695
+ return target.origin === origin ? target.toString() : null
696
+ }
697
+
698
+ function isControlOrSpace(character: string): boolean {
699
+ const code = character.charCodeAt(0)
700
+ return code <= 0x20 || code === 0x7f
701
+ }
702
+
650
703
  function invalidKey(): Denial {
651
704
  return { outcome: 'deny', status: 401, reason: 'invalid_key' }
652
705
  }
@@ -738,10 +791,13 @@ function isDocumentNavigation(headers: Headers): boolean {
738
791
  }
739
792
 
740
793
  /**
741
- * The `Authorization` of an anonymous request is the application's only on a row that forwards it, and never when it
742
- * carries a platform key. The proxy reads no credential from it either way: no key lookup, no session.
794
+ * The `Authorization` of an anonymous request goes on only on a `protocol` row, whose receiver authenticates the caller
795
+ * itself (ADR-0069: the ops key reaches framework code only), and on a webhook row that forwards it, never with a
796
+ * platform key. The proxy reads no credential from it either way: no key lookup, no session.
743
797
  */
744
798
  function forwardsAuthorization(row: ManifestEndpointRow, request: ForwardedRequest): boolean {
745
799
  const authorization = request.headers.get('Authorization')
746
- return 'authorization' in row && row.authorization === 'forward' && authorization !== null && !carriesPlatformKey(authorization, API_KEY_PREFIX)
800
+ if (authorization === null) return false
801
+ if (row.kind === 'protocol') return true
802
+ return 'authorization' in row && row.authorization === 'forward' && !carriesPlatformKey(authorization, API_KEY_PREFIX)
747
803
  }
package/src/brand.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The client's brand on fabrika's own pages (ADR-0066). An installation serves one client, so its logo is one setting
3
+ * of the installation: IAM keeps it, the console's Settings writes it, and the proxy manifest carries it to the pages the
4
+ * proxy answers with on every application host. A page shows it where it would otherwise draw the fabrika mark.
5
+ *
6
+ * A logo is an image a page embeds as a `data:` URI in an `<img>`, never markup: an SVG in an image runs no script, and
7
+ * a page that carries it loads nothing. Every consumer checks it here, the IAM write, the manifest parser and the
8
+ * console's own pre-check, so a logo that one of them admits passes the others. This module imports zod and nothing
9
+ * else, so the browser can use it.
10
+ */
11
+
12
+ import { z } from 'zod'
13
+
14
+ export const BRAND_IMAGE_TYPES = ['image/png', 'image/webp', 'image/svg+xml'] as const
15
+
16
+ export type BrandImageType = (typeof BRAND_IMAGE_TYPES)[number]
17
+
18
+ /** The most one image may weigh, decoded. A page carries it in every copy Caddy serves, so it stays a logo's size. */
19
+ export const MAX_BRAND_IMAGE_BYTES = 32 * 1024
20
+
21
+ /** One image of the brand: its type and its bytes as canonical base64. */
22
+ export interface BrandImage {
23
+ readonly type: BrandImageType
24
+ readonly base64: string
25
+ }
26
+
27
+ /** The logo for a light page, and the one for a dark page when the light one would not read there. */
28
+ export interface InstallationBrand {
29
+ readonly light: BrandImage
30
+ readonly dark?: BrandImage
31
+ }
32
+
33
+ export type BrandImageProblem = 'type' | 'encoding' | 'size' | 'content'
34
+
35
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
36
+ const MAX_BASE64_LENGTH = Math.ceil(MAX_BRAND_IMAGE_BYTES / 3) * 4
37
+
38
+ /** Why an image cannot be the brand, or null. The type must be one of three and must be what the bytes are. */
39
+ export function brandImageProblem(image: { readonly type: string; readonly base64: string }): BrandImageProblem | null {
40
+ if (!isBrandImageType(image.type)) return 'type'
41
+ if (image.base64.length === 0 || !BASE64.test(image.base64)) return 'encoding'
42
+ if (image.base64.length > MAX_BASE64_LENGTH) return 'size'
43
+ const bytes = decode(image.base64)
44
+ // Base64 with stray bits in its last character decodes to the same bytes; only the canonical spelling is one image.
45
+ if (bytes === null || brandImageBase64(bytes) !== image.base64) return 'encoding'
46
+ if (bytes.length > MAX_BRAND_IMAGE_BYTES) return 'size'
47
+ return sniff(bytes) === image.type ? null : 'content'
48
+ }
49
+
50
+ export function isBrandImageType(value: string): value is BrandImageType {
51
+ return BRAND_IMAGE_TYPES.some((type) => type === value)
52
+ }
53
+
54
+ export const brandImageSchema = z.object({
55
+ type: z.enum(BRAND_IMAGE_TYPES),
56
+ base64: z.string(),
57
+ }).strict().superRefine((image, ctx) => {
58
+ const problem = brandImageProblem(image)
59
+ if (problem !== null) ctx.addIssue({ code: 'custom', message: `not a brand image: ${problem}` })
60
+ }) satisfies z.ZodType<BrandImage, z.ZodTypeDef, unknown>
61
+
62
+ export const installationBrandSchema = z.object({
63
+ light: brandImageSchema,
64
+ dark: brandImageSchema.optional(),
65
+ }).strict() satisfies z.ZodType<InstallationBrand, z.ZodTypeDef, unknown>
66
+
67
+ /** The image as the `src` of an `<img>`. Base64 holds no `{`, so a Caddy `static_response` body keeps it as it is. */
68
+ export function brandImageDataUri(image: BrandImage): string {
69
+ return `data:${image.type};base64,${image.base64}`
70
+ }
71
+
72
+ /** What the bytes are, read from their start, never from what the uploader called them. */
73
+ function sniff(bytes: Uint8Array): BrandImageType | null {
74
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png'
75
+ if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) return 'image/webp'
76
+ return isSvg(bytes) ? 'image/svg+xml' : null
77
+ }
78
+
79
+ /**
80
+ * UTF-8 text whose first element is `<svg`, after an optional byte order mark, an XML declaration, comments and a
81
+ * doctype. An image of type SVG renders inert in an `<img>`, so this only has to stop another kind of file posing as one.
82
+ */
83
+ function isSvg(bytes: Uint8Array): boolean {
84
+ let text: string
85
+ try {
86
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
87
+ } catch {
88
+ return false
89
+ }
90
+ let rest = text.replace(/^/, '')
91
+ for (;;) {
92
+ const trimmed = rest.replace(/^\s+/, '')
93
+ const prologue = /^(?:<\?xml[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE\s+svg[^>]*>)/i.exec(trimmed)
94
+ if (prologue === null) return /^<svg[\s>]/.test(trimmed)
95
+ rest = trimmed.slice(prologue[0].length)
96
+ }
97
+ }
98
+
99
+ function startsWith(bytes: Uint8Array, prefix: readonly number[]): boolean {
100
+ return bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte)
101
+ }
102
+
103
+ function decode(base64: string): Uint8Array | null {
104
+ try {
105
+ const binary = atob(base64)
106
+ const bytes = new Uint8Array(binary.length)
107
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
108
+ return bytes
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ /** Canonical base64 of `bytes`, the spelling the check requires. Chunked, so a large file stays within `fromCharCode`'s arguments. */
115
+ export function brandImageBase64(bytes: Uint8Array): string {
116
+ let binary = ''
117
+ for (let start = 0; start < bytes.length; start += 0x8000) {
118
+ binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000))
119
+ }
120
+ return btoa(binary)
121
+ }
package/src/cache.ts CHANGED
Binary file
package/src/constants.ts CHANGED
@@ -49,6 +49,15 @@ export const APP_QUERY_PARAM = 'app'
49
49
  */
50
50
  export const LOGIN_REDIRECT_PARAM = 'redirect'
51
51
 
52
+ /**
53
+ * Reserved on every app host beside the handoff callback, and answered before endpoint selection: it clears the app
54
+ * session and sends the browser to IAM's release (ADR-0067 §5). It takes the return path in `LOGIN_REDIRECT_PARAM`.
55
+ */
56
+ export const AUTH_SIGNOUT_PATH = '/__fabrika/auth/signout'
57
+
58
+ /** IAM's path that revokes the browser's terminal sessions and returns it to the application (ADR-0067 §5). */
59
+ export const SIGNOUT_RELEASE_PATH = '/auth/terminals/release'
60
+
52
61
  /** The JWKS cache TTL and the unknown-`kid` refetch cooldown, owned by the verifier both planes share. */
53
62
  export { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from '@fabrika/jwks'
54
63
 
package/src/handoff.ts CHANGED
@@ -15,6 +15,14 @@ export async function createHandoffAttempt(): Promise<HandoffAttempt> {
15
15
  return { state, verifier, challenge: await handoffChallenge(verifier) }
16
16
  }
17
17
 
18
+ /**
19
+ * A verifier made for no challenge. IAM consumes a code before it compares the verifier, so redeeming a code with this
20
+ * one only spends the code.
21
+ */
22
+ export function unmatchedVerifier(): string {
23
+ return randomToken(32)
24
+ }
25
+
18
26
  /** S256, matching PKCE: the verifier never rides in a URL or reaches IAM through the browser. */
19
27
  export async function handoffChallenge(verifier: string): Promise<string> {
20
28
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export { cacheKey, MemoryTokenCache } from './cache'
16
16
  export type { CachedToken, TokenCache } from './cache'
17
17
  export {
18
18
  APP_QUERY_PARAM,
19
+ AUTH_SIGNOUT_PATH,
19
20
  CLIENT_ADDRESS_HEADER,
20
21
  DEFAULT_HEALTH_PATH,
21
22
  DEFAULT_IAM_TIMEOUT_MS,
@@ -32,6 +33,7 @@ export {
32
33
  PREFLIGHT_HEADER,
33
34
  PROXY_TOKEN_HEADER,
34
35
  REQUEST_ID_HEADER,
36
+ SIGNOUT_RELEASE_PATH,
35
37
  UNTRUSTED_FORWARD_HEADERS,
36
38
  } from './constants'
37
39
  export { carriesPlatformKey, readBearer, readCookie } from './credentials'
@@ -41,6 +43,8 @@ export type { DelegationIssuer, DelegationRequest, HttpIamGatewayOptions, IamGat
41
43
  export { consoleLogger, redactPath, redactUrl, silentLogger } from './log'
42
44
  export type { LogFields, LogValue, ProxyLogger } from './log'
43
45
  export { findOAuthMetadata, OAUTH_METADATA_VERIFY_PATH, oauthMetadataResponse } from './oauth'
46
+ export { errorAnswer, errorPage, FETCH_MODE_HEADER, isPageNavigation, PAGE_CONTENT_TYPE, pageResponse, renderPage } from './pages'
47
+ export type { ErrorPageStatus, PageAction, PageContent } from './pages'
44
48
  export { createVerifyService } from './service'
45
49
  export type { VerifyService, VerifyServiceConfig } from './service'
46
50
  export { TokenVerifier } from './verifier'
package/src/manifest.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ANY_ORIGIN_KINDS,
3
3
  APP_ENVIRONMENT_SEPARATOR,
4
+ type BodyAdmission,
4
5
  type Caller,
5
6
  compileEndpointTable,
6
7
  type ContractArtifact,
@@ -17,10 +18,16 @@ import {
17
18
  type InstallationEndpointTable,
18
19
  isDigest,
19
20
  MAX_CACHE_AGE_SECONDS,
21
+ OPS_CONTENT_TYPE,
22
+ OPS_METHOD,
23
+ OPS_PREFIX,
24
+ OPS_ROW_ID,
25
+ OPS_ROW_PATH,
20
26
  parseAppEnvironmentId,
21
27
  requirementDigest,
22
28
  } from '@fabrika/auth-core'
23
29
  import { z } from 'zod'
30
+ import { type InstallationBrand, installationBrandSchema } from './brand'
24
31
  import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from './headers'
25
32
  import { type OAuthProtectedResource, oauthProtectedResourceSchema, oauthScopesSchema } from './oauth-resource'
26
33
 
@@ -61,7 +68,8 @@ export interface ContractProxyApp extends ProxyAppBase {
61
68
  /**
62
69
  * An installation service protected by an endpoint table (ADR-0022 "Installation entries"). Only trusted platform
63
70
  * composition writes `installation: true`; its rows may use the forms no application may declare, and its hop keeps
64
- * `Authorization`, sets no proxy secret and leaves the service's answers untouched.
71
+ * `Authorization`, sets no proxy secret (IAM's session-owner route excepted, for attribution only) and leaves the
72
+ * service's answers untouched.
65
73
  */
66
74
  export interface InstallationProxyApp extends ProxyAppBase {
67
75
  installation: true
@@ -92,8 +100,8 @@ interface ProxyEndpointExtras {
92
100
  delegation: EndpointDelegation | null
93
101
  }
94
102
 
95
- /** An endpoint row as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
96
- export type ProxyEndpointRow =
103
+ /** A row a contract declares, as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
104
+ export type DeclaredProxyEndpointRow =
97
105
  | (
98
106
  & Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin' | 'response'>
99
107
  & ProxyEndpointExtras
@@ -109,6 +117,42 @@ export type ProxyEndpointRow =
109
117
  }
110
118
  )
111
119
 
120
+ /** An endpoint row of an application table: a declared row, or the ops row that only platform composition adds. */
121
+ export type ProxyEndpointRow = DeclaredProxyEndpointRow | OpsProxyEndpointRow
122
+
123
+ /**
124
+ * The canonical ops row (ADR-0069): Control's channel to an environment's object, which platform composition adds to an
125
+ * application's table for a framework release that serves it. The parser admits only `opsProxyRow()`, field for field.
126
+ */
127
+ export interface OpsProxyEndpointRow {
128
+ id: string
129
+ kind: 'protocol'
130
+ method: typeof OPS_METHOD
131
+ path: string
132
+ callers: []
133
+ requirement: null
134
+ admission: BodyAdmission
135
+ delegation: null
136
+ digest: string
137
+ }
138
+
139
+ /** The ops row's body limit: every ops request is a small JSON object. */
140
+ export const OPS_ROW_MAX_BYTES = 64 * 1024
141
+
142
+ /** The ops row, digested like every row; the provider composes it and the framework checks `X-Fabrika-Endpoint` against it. */
143
+ export function opsProxyRow(): OpsProxyEndpointRow {
144
+ const rules = {
145
+ kind: 'protocol',
146
+ method: OPS_METHOD,
147
+ path: OPS_ROW_PATH,
148
+ callers: [],
149
+ requirement: null,
150
+ admission: { contentTypes: [OPS_CONTENT_TYPE], maxBytes: OPS_ROW_MAX_BYTES },
151
+ delegation: null,
152
+ } satisfies Omit<OpsProxyEndpointRow, 'id' | 'digest'>
153
+ return { id: OPS_ROW_ID, ...rules, digest: requirementDigest({ ...rules, oauth: null }) }
154
+ }
155
+
112
156
  /** An installation row as the proxy applies it: the row with its digest and no delegation. */
113
157
  export type InstallationProxyEndpointRow = InstallationEndpointRow & { digest: string; delegation: null }
114
158
 
@@ -117,6 +161,11 @@ export type ManifestEndpointRow = ProxyEndpointRow | InstallationProxyEndpointRo
117
161
 
118
162
  export interface ProxyManifest {
119
163
  apps: ProxyApp[]
164
+ /**
165
+ * The installation's client logo for the pages the proxy answers with (ADR-0066). Absent, a page draws the fabrika
166
+ * mark. A producer sends it only to a proxy whose source parses it: an older parser refuses the key.
167
+ */
168
+ brand?: InstallationBrand
120
169
  }
121
170
 
122
171
  const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
@@ -189,7 +238,10 @@ const endpointRuleFields = {
189
238
  admission: admissionSchema.nullable(),
190
239
  digest: z.string().refine(isDigest),
191
240
  }
192
- /** One row of a contract app's endpoint table; it refuses a digest that does not describe the row's rules. */
241
+ /**
242
+ * One row a contract declares, of a contract app's endpoint table; it refuses a digest that does not describe the row's
243
+ * rules. It never admits the ops row, which only `contractProxyAppSchema` reads.
244
+ */
193
245
  export const proxyEndpointRowSchema = z.union([
194
246
  z.object({
195
247
  ...endpointRuleFields,
@@ -237,13 +289,28 @@ export const proxyEndpointRowSchema = z.union([
237
289
  (row) => row.digest === requirementDigest(row),
238
290
  'endpoint digest does not match its rules',
239
291
  ) satisfies z.ZodType<
240
- ProxyEndpointRow,
292
+ DeclaredProxyEndpointRow,
241
293
  z.ZodTypeDef,
242
294
  unknown
243
295
  >
244
296
 
297
+ const OPS_ROW = opsProxyRow()
298
+
299
+ /** The ops row exactly as `opsProxyRow()` builds it: every field a literal, its digest included. */
300
+ const opsProxyEndpointRowSchema = z.object({
301
+ id: z.literal(OPS_ROW_ID),
302
+ kind: z.literal('protocol'),
303
+ method: z.literal(OPS_METHOD),
304
+ path: z.literal(OPS_ROW_PATH),
305
+ callers: z.tuple([]),
306
+ requirement: z.null(),
307
+ admission: z.object({ contentTypes: z.tuple([z.literal(OPS_CONTENT_TYPE)]), maxBytes: z.literal(OPS_ROW_MAX_BYTES) }).strict(),
308
+ delegation: z.null(),
309
+ digest: z.literal(OPS_ROW.digest),
310
+ }).strict() satisfies z.ZodType<OpsProxyEndpointRow, z.ZodTypeDef, unknown>
311
+
245
312
  /** Only an RPC procedure, a public procedure and a form open to another origin, and only the anonymous two to any (ADR-0022). */
246
- function crossOriginAllowed(kind: ProxyEndpointRow['kind'], form: CrossOriginForm | undefined): boolean {
313
+ function crossOriginAllowed(kind: DeclaredProxyEndpointRow['kind'], form: CrossOriginForm | undefined): boolean {
247
314
  return form === undefined || (CROSS_ORIGIN_KINDS.includes(kind) && (form !== 'any' || ANY_ORIGIN_KINDS.includes(kind)))
248
315
  }
249
316
 
@@ -275,7 +342,7 @@ const cspOriginsSchema = z.object({
275
342
  export const contractProxyAppSchema = z.object({
276
343
  ...proxyAppFields,
277
344
  scheme: proxyAppScheme,
278
- endpoints: z.array(proxyEndpointRowSchema),
345
+ endpoints: z.array(z.union([proxyEndpointRowSchema, opsProxyEndpointRowSchema])),
279
346
  reservedPrefixes: z.array(z.string()),
280
347
  snapshot: z.string().refine(isDigest),
281
348
  csp: cspOriginsSchema,
@@ -283,6 +350,10 @@ export const contractProxyAppSchema = z.object({
283
350
  crossOrigins: z.array(z.string().refine(isCrossOrigin)).nonempty().max(MAX_CROSS_ORIGINS).optional(),
284
351
  }).strict().superRefine((app, ctx) => {
285
352
  checkCrossOrigins(app, ctx)
353
+ // Only the ops row lies under the ops prefix: another row there would be an application route at a platform path.
354
+ if (app.endpoints.some((row) => row.path.startsWith(OPS_PREFIX) && row.kind !== 'protocol')) {
355
+ ctx.addIssue({ code: 'custom', message: 'only the ops row serves the ops prefix' })
356
+ }
286
357
  checkEndpointTable(app, ctx)
287
358
  }) satisfies z.ZodType<ContractProxyApp, z.ZodTypeDef, unknown>
288
359
 
@@ -373,9 +444,14 @@ export const proxyAppSchema = z.union([contractProxyAppSchema, installationProxy
373
444
  * The manifest form of a compiled contract: every endpoint's rules with their digest and delegation, the reserved
374
445
  * prefixes, the contract digest and the named CSP origins. Producers call this; they never assemble rows by hand.
375
446
  * `digest` is the one the application's own release computes; Control passes it for an artifact it upcast from an
376
- * older release's (ADR-0062).
447
+ * older release's (ADR-0062). `ops` adds the ops row, which only platform composition asks for and the artifact never
448
+ * carries.
377
449
  */
378
- export function contractProxyTable(artifact: ContractArtifact, digest: string = contractDigest(artifact)): ContractProxyTable {
450
+ export function contractProxyTable(
451
+ artifact: ContractArtifact,
452
+ digest: string = contractDigest(artifact),
453
+ options: { readonly ops?: boolean } = {},
454
+ ): ContractProxyTable {
379
455
  return {
380
456
  endpoints: artifact.endpoints.map((row): ProxyEndpointRow => {
381
457
  const rules = {
@@ -400,13 +476,68 @@ export function contractProxyTable(artifact: ContractArtifact, digest: string =
400
476
  delegation: endpointDelegation(artifact, row),
401
477
  }
402
478
  return { ...credentialed, digest: requirementDigest(credentialed) }
403
- }),
479
+ }).concat(options.ops === true ? [opsProxyRow()] : []),
404
480
  reservedPrefixes: [...artifact.reservedPrefixes],
405
481
  snapshot: digest,
406
482
  csp: cspOrigins(artifact.csp),
407
483
  }
408
484
  }
409
485
 
486
+ /** Where and how a producer serves one contract application. */
487
+ export interface ContractEntryPlacement {
488
+ readonly id: string
489
+ readonly hosts: readonly string[]
490
+ readonly upstream: string
491
+ readonly scheme: 'http' | 'https'
492
+ /** The approved contract's table, from `contractProxyTable`. */
493
+ readonly table: ContractProxyTable
494
+ readonly operationsOrigin?: string
495
+ /** The environment's list from Control's registry; an empty one is omitted. */
496
+ readonly crossOrigins?: readonly string[]
497
+ }
498
+
499
+ /**
500
+ * What the proxy of one older platform release parses of an application's manifest entry: a row, and the entry's CSP
501
+ * directives, each by that release's own parser (ADR-0062); and whether it serves the sign-out (ADR-0067 §5).
502
+ */
503
+ export interface OlderProxyReader {
504
+ readonly row: (row: ProxyEndpointRow) => boolean
505
+ readonly csp: (csp: CspOrigins) => boolean
506
+ readonly signsOut: boolean
507
+ }
508
+
509
+ /**
510
+ * The proxies of the older releases a pin may run whose parsers Control holds. A pin's release is not recorded, so what
511
+ * any of them reads is what the pin may read.
512
+ */
513
+ export type OlderProxyReaders = readonly [OlderProxyReader, ...OlderProxyReader[]]
514
+
515
+ /**
516
+ * A contract application's manifest entry: the one form every producer builds. Refused when the proxy's own parser
517
+ * would refuse it, naming the fields and never their values, because an origin with userinfo may carry a key.
518
+ */
519
+ export function contractProxyEntry(placement: ContractEntryPlacement): ContractProxyApp {
520
+ const { table, operationsOrigin, crossOrigins = [] } = placement
521
+ const entry: ContractProxyApp = {
522
+ id: placement.id,
523
+ hosts: [...placement.hosts],
524
+ upstream: placement.upstream,
525
+ scheme: placement.scheme,
526
+ endpoints: table.endpoints,
527
+ reservedPrefixes: table.reservedPrefixes,
528
+ snapshot: table.snapshot,
529
+ csp: table.csp,
530
+ ...(operationsOrigin === undefined ? {} : { operationsOrigin }),
531
+ ...(crossOrigins.length === 0 ? {} : { crossOrigins: [...crossOrigins] }),
532
+ }
533
+ const parsed = contractProxyAppSchema.safeParse(entry)
534
+ if (!parsed.success) {
535
+ const fields = new Set(parsed.error.issues.map((issue) => issue.path.length === 0 ? issue.message : issue.path.join('.')))
536
+ throw new Error(`proxy entry \`${placement.id}\` is one the proxy manifest refuses (${[...fields].join('; ')})`)
537
+ }
538
+ return entry
539
+ }
540
+
410
541
  /** What a producer adds to an installation service's placement: the output of `serviceProxyTable`. */
411
542
  export type InstallationProxyTable = Pick<InstallationProxyApp, 'installation' | 'endpoints' | 'reservedPrefixes'>
412
543
 
@@ -440,6 +571,7 @@ export function findDuplicateProxyHost(apps: readonly Pick<ProxyApp, 'hosts'>[])
440
571
 
441
572
  export const proxyManifestSchema = z.object({
442
573
  apps: z.array(proxyAppSchema),
574
+ brand: installationBrandSchema.optional(),
443
575
  }).strict().superRefine(({ apps }, ctx) => {
444
576
  if (new Set(apps.map((app) => app.id)).size !== apps.length) ctx.addIssue({ code: 'custom', message: 'duplicate proxy app id' })
445
577
  if (findDuplicateProxyHost(apps) !== null) ctx.addIssue({ code: 'custom', message: 'duplicate proxy host' })