@fabrika/app 0.5.1 → 0.5.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/app",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,14 +93,14 @@
93
93
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
94
94
  },
95
95
  "dependencies": {
96
- "@fabrika/auth": "0.5.1",
97
- "@fabrika/auth-core": "0.5.1",
98
- "@fabrika/db": "0.5.1",
99
- "@fabrika/email": "0.5.1",
100
- "@fabrika/jwks": "0.5.1",
101
- "@fabrika/platform": "0.5.1",
102
- "@fabrika/platform-node": "0.5.1",
103
- "@fabrika/proxy-core": "0.5.1",
96
+ "@fabrika/auth": "0.5.3",
97
+ "@fabrika/auth-core": "0.5.3",
98
+ "@fabrika/db": "0.5.3",
99
+ "@fabrika/email": "0.5.3",
100
+ "@fabrika/jwks": "0.5.3",
101
+ "@fabrika/platform": "0.5.3",
102
+ "@fabrika/platform-node": "0.5.3",
103
+ "@fabrika/proxy-core": "0.5.3",
104
104
  "jose": "^5.9.0"
105
105
  },
106
106
  "peerDependencies": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.5.1
3
+ version: 0.5.3
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "0.5.1",
23
- "@fabrika/db": "0.5.1",
24
- "@fabrika/provider-zerops": "0.5.1",
25
- "@fabrika/proxy-core": "0.5.1",
26
- "@fabrika/ui": "0.5.1",
22
+ "@fabrika/app": "0.5.3",
23
+ "@fabrika/db": "0.5.3",
24
+ "@fabrika/provider-zerops": "0.5.3",
25
+ "@fabrika/proxy-core": "0.5.3",
26
+ "@fabrika/ui": "0.5.3",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "0.5.1",
34
- "@fabrika/cli": "0.5.1",
35
- "@fabrika/platform": "0.5.1",
33
+ "@fabrika/check": "0.5.3",
34
+ "@fabrika/cli": "0.5.3",
35
+ "@fabrika/platform": "0.5.3",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -522,6 +522,22 @@ the invocation's transaction. `'write'` (a mutation, a job, a webhook) also inse
522
522
  then names the chain columns itself. Use it for an operator's cross-scope view, never to dodge a capability the caller
523
523
  should hold. The generated tests skip the other-scope leak case for such an endpoint.
524
524
 
525
+ ## Another origin: `crossOrigin`
526
+
527
+ A page on another site (a partner's page, a separate marketing site, another application in the browser) calls an RPC
528
+ endpoint only when the endpoint says `crossOrigin: true` beside its `access`, and an operator lists that page's origin
529
+ for the environment in Control (`--cross-origin=https://partner.example` on `fabrika control apps environments put`). Both halves are
530
+ needed: the contract says which endpoints, the environment says from where, so staging and production can list
531
+ different pages. The approver sees the flag like any other access change.
532
+
533
+ - **A key, never a session.** The page sends `Authorization: Bearer px_…` (a machine key, or a person's own key). A
534
+ person's session is never read for a request from a listed origin: the answer is 401 without a login bounce.
535
+ - **The proxy answers CORS, the handler never does.** The proxy answers the preflight and sets
536
+ `Access-Control-Allow-Origin` on the answer; it deletes every `Access-Control-*` header a handler sets.
537
+ - **Only RPC procedures.** A link procedure, an export, an SPA, a download and an upload refuse `crossOrigin`. A form
538
+ takes it too (`references/public-intake.md`).
539
+ - **Test it** with `testKit(app, { crossOrigins: ['https://partner.example'] })` and a request carrying that `Origin`.
540
+
525
541
  ## Members
526
542
 
527
543
  `ctx.members` of an endpoint `on` or `in` a dimension reads the members of the nearest dimension, its own or one
@@ -405,6 +405,10 @@ export const contract = app.contract((c) => ({
405
405
  `<prefix>/__fabrika-config.json` as `public: { <VARIABLE>: <value> }`, and the approver sees each name. Never name
406
406
  a secret there: the contract refuses a variable that is both.
407
407
  `rate: { perClient, perForm }` (per minute, 10 and 100 by default) answers 429 past either limit.
408
+ - **A form on someone else's page.** A form served from this application's own SPA needs nothing. A form a partner's
409
+ page or a separate marketing site submits needs `crossOrigin`: `true` for the origins an operator lists for the
410
+ environment in Control, `'*'` for any site (an embeddable widget). The proxy answers the browser's preflight and
411
+ the page can read the constant answer. Keep the honeypot, the challenge and the rate limit: `'*'` invites every site.
408
412
  - **Zerops shares one client address.** Until the platform reads the visitor's address behind the Zerops balancer,
409
413
  every visitor counts as one client there, so `perClient` acts as a second per-form limit: set it to the form's
410
414
  expected peak, never lower it to stop a flood. The challenge is the defense that does not depend on the address.
@@ -2,10 +2,12 @@
2
2
  // object is strict and every field required, so a value that is not exactly an artifact is refused, not coerced.
3
3
 
4
4
  import {
5
+ ANY_ORIGIN_KINDS,
5
6
  type ArrayShape,
6
7
  type AttachmentShape,
7
8
  BUILTIN_ROLE_KEYS,
8
9
  type ContractArtifact,
10
+ CROSS_ORIGIN_KINDS,
9
11
  displayNameProblem,
10
12
  type EndpointRow,
11
13
  JOB_MAILERS,
@@ -134,12 +136,14 @@ const endpointRow: z.ZodType<EndpointRow> = z.union([
134
136
  kind: z.enum(['query', 'mutation', 'spa', 'upload', 'download']),
135
137
  callers: z.tuple([caller], caller),
136
138
  requirement,
139
+ crossOrigin: z.literal('environment').optional(),
137
140
  }),
138
141
  z.strictObject({
139
142
  ...rowBase,
140
143
  kind: z.enum(['health', 'form', 'webhook', 'link-spa']),
141
144
  callers: z.tuple([]),
142
145
  requirement: z.null(),
146
+ crossOrigin: z.enum(['environment', 'any']).optional(),
143
147
  }),
144
148
  // A public SPA is a fixed static row: GET only, no body, no schemas.
145
149
  z.strictObject({
@@ -152,7 +156,13 @@ const endpointRow: z.ZodType<EndpointRow> = z.union([
152
156
  callers: z.tuple([]),
153
157
  requirement: z.null(),
154
158
  }),
155
- ])
159
+ ]).refine(
160
+ // Only an RPC procedure and a form open to another origin, and only a form to any (ADR-0022).
161
+ (row) =>
162
+ !('crossOrigin' in row) || row.crossOrigin === undefined
163
+ || (CROSS_ORIGIN_KINDS.includes(row.kind) && (row.crossOrigin !== 'any' || ANY_ORIGIN_KINDS.includes(row.kind))),
164
+ 'this endpoint kind cannot be called cross-origin',
165
+ )
156
166
 
157
167
  const scopeBinding = z.discriminatedUnion('kind', [
158
168
  z.strictObject({ kind: z.literal('app') }),
@@ -11,6 +11,7 @@ import {
11
11
  canonicalizeArtifact,
12
12
  type ChallengeProvider,
13
13
  type ContractArtifact,
14
+ type CrossOriginForm,
14
15
  type CspExtensions,
15
16
  type CspSource,
16
17
  type DeclaredReach,
@@ -527,7 +528,15 @@ class ArtifactBuilder {
527
528
  this.#prefix(where, mount.path)
528
529
  this.#objectSchema(`${where} input`, mount.input)
529
530
  this.#nonEmpty(`${where} appendTo`, mount.appendTo)
530
- this.#anonymous(name, 'form', 'POST', mount.path, this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.formMaxBytes), mount.input)
531
+ this.#anonymous(
532
+ name,
533
+ 'form',
534
+ 'POST',
535
+ mount.path,
536
+ this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.formMaxBytes),
537
+ mount.input,
538
+ this.#crossOrigin(where, mount.crossOrigin, true),
539
+ )
531
540
  return {
532
541
  kind: 'form',
533
542
  path: mount.path,
@@ -739,6 +748,7 @@ class ArtifactBuilder {
739
748
  const id = `${name}.${path}`
740
749
  const requirement = this.#linkRequirement(`procedure '${id}'`, procedure.access, mount)
741
750
  this.#procedureSchemas(`procedure '${id}'`, procedure)
751
+ this.#noCrossOrigin(`procedure '${id}'`, procedure.crossOrigin, 'a link holder')
742
752
  this.#declare(`procedure '${id}'`, id, procedure.declared, { kind: 'link', readOnly: procedure.type === 'query' })
743
753
  if (requirement !== null) {
744
754
  this.#row({
@@ -776,6 +786,9 @@ class ArtifactBuilder {
776
786
  const where = `procedure '${id}'`
777
787
  this.#procedureSchemas(where, procedure)
778
788
  const requirement = this.#requirement(where, procedure)
789
+ const crossOrigin = source.kind === 'mount'
790
+ ? this.#crossOrigin(where, procedure.crossOrigin, false)
791
+ : this.#noCrossOrigin(where, procedure.crossOrigin, 'a consuming application, from its server')
779
792
  if (requirement !== null) {
780
793
  this.#row({
781
794
  id,
@@ -788,10 +801,38 @@ class ArtifactBuilder {
788
801
  admission: JSON_ADMISSION,
789
802
  input: procedure.input,
790
803
  output: this.#output(where, procedure),
804
+ ...crossOrigin,
791
805
  })
792
806
  }
793
807
  }
794
808
 
809
+ /**
810
+ * A procedure's or a form's `crossOrigin` (ADR-0022 "Cross-origin endpoints"): `true` admits the origins the
811
+ * environment lists, `'*'` (a form only, `any`) every origin. Absent stays absent, so the row keeps its digest.
812
+ */
813
+ #crossOrigin(where: string, value: unknown, anyOrigin: boolean): { readonly crossOrigin?: CrossOriginForm } {
814
+ if (value === undefined) {
815
+ return {}
816
+ }
817
+ if (value === true) {
818
+ return { crossOrigin: 'environment' }
819
+ }
820
+ if (anyOrigin && value === '*') {
821
+ return { crossOrigin: 'any' }
822
+ }
823
+ const allowed = anyOrigin ? "crossOrigin: true for the environment's listed origins, or crossOrigin: '*' for any" : 'crossOrigin: true'
824
+ this.#problem(where, `crossOrigin ${JSON.stringify(value)} is not a cross-origin form (allowed: ${allowed})`)
825
+ return {}
826
+ }
827
+
828
+ /** A procedure no page on another origin may call: `caller` is who calls it instead. */
829
+ #noCrossOrigin(where: string, value: unknown, caller: string): { readonly crossOrigin?: CrossOriginForm } {
830
+ if (value !== undefined) {
831
+ this.#problem(where, `is called by ${caller}, never cross-origin (only an RPC procedure of c.rpc and a form take crossOrigin)`)
832
+ }
833
+ return {}
834
+ }
835
+
795
836
  /**
796
837
  * A procedure's answer: its output schema, or for an `attachment()` the file it encodes from the rows. A column is a
797
838
  * scalar cell, the name is fixed and safe in a header, and each limit is within `ATTACHMENT_LIMITS`.
@@ -854,8 +895,21 @@ class ArtifactBuilder {
854
895
  path: string,
855
896
  admission: BodyAdmission | null,
856
897
  input: SchemaShape | null,
898
+ crossOrigin: { readonly crossOrigin?: CrossOriginForm } = {},
857
899
  ): void {
858
- this.#row({ id: name, source: { kind: 'mount', name }, kind, method, path, callers: [], requirement: null, admission, input, output: null })
900
+ this.#row({
901
+ id: name,
902
+ source: { kind: 'mount', name },
903
+ kind,
904
+ method,
905
+ path,
906
+ callers: [],
907
+ requirement: null,
908
+ admission,
909
+ input,
910
+ output: null,
911
+ ...crossOrigin,
912
+ })
859
913
  }
860
914
 
861
915
  #row(row: EndpointRow): void {
@@ -47,11 +47,11 @@ export interface BuilderFields {
47
47
  }
48
48
 
49
49
  export const BUILDER_FIELDS: Readonly<Record<Builder, BuilderFields>> = {
50
- procedure: { options: ['input', 'output', 'access'], owned: ['~kind', 'type'] },
50
+ procedure: { options: ['input', 'output', 'access', 'crossOrigin'], owned: ['~kind', 'type'] },
51
51
  rpc: { options: ['callers'], owned: ['kind', 'prefix', 'procedures'] },
52
52
  link: { options: ['spa', 'dimension', 'grants', 'issuedBy', 'maxDays', 'procedures'], owned: ['kind', 'prefix'] },
53
53
  spa: { options: ['dir', 'access', 'public'], owned: ['kind', 'prefix'] },
54
- form: { options: ['input', 'appendTo', 'maxBytes', 'rate', 'honeypot', 'challenge', 'then'], owned: ['kind', 'path'] },
54
+ form: { options: ['input', 'appendTo', 'maxBytes', 'rate', 'honeypot', 'challenge', 'then', 'crossOrigin'], owned: ['kind', 'path'] },
55
55
  webhook: { options: ['scheme', 'boundTo', 'actions', 'input', 'maxBytes'], owned: ['kind', 'path'] },
56
56
  files: { options: ['owner', 'read', 'write', 'delete', 'maxBytes', 'types', 'maxPerOwner', 'link', 'scan'], owned: ['kind'] },
57
57
  cron: { options: ['actions', 'scope', 'timeout', 'retries'], owned: ['kind', 'schedule'] },
@@ -155,6 +155,8 @@ export interface ProcedureDeclaration<S, Access = AccessDeclaration> extends Opt
155
155
  readonly input: S | null
156
156
  readonly output: S
157
157
  readonly access: Access
158
+ /** `true` opens the procedure to the environment's listed origins, as written; `buildArtifact` refuses anything else. */
159
+ readonly crossOrigin?: unknown
158
160
  /** What a procedure of the staged kit declares off the paved road; a `defineContract` procedure has none. */
159
161
  readonly declared?: unknown
160
162
  /**
@@ -237,6 +239,8 @@ export interface FormMountDeclaration<S> extends OptionsRecord {
237
239
  readonly challenge?: { readonly provider: ChallengeProvider; readonly secret: string; readonly siteKey?: string }
238
240
  /** The cron job of the contract that each accepted submission kicks. */
239
241
  readonly then?: string
242
+ /** `true` (the environment's listed origins) or `'*'` (any origin), as written; `buildArtifact` refuses anything else. */
243
+ readonly crossOrigin?: unknown
240
244
  }
241
245
 
242
246
  export interface WebhookMountDeclaration<S> extends OptionsRecord {
@@ -220,6 +220,8 @@ export interface ProcedureDef<I, O, Acc> extends Owned<'~kind' | 'type'> {
220
220
  readonly input?: I & NotLoose<I>
221
221
  readonly output: O & NotLoose<O>
222
222
  readonly access: Acc
223
+ /** A page on one of the environment's listed origins may call it, with a key (ADR-0022). An RPC procedure only. */
224
+ readonly crossOrigin?: true
223
225
  }
224
226
 
225
227
  export interface RpcMount<C extends readonly [RpcCaller, ...RpcCaller[]], T extends ProcedureTree> extends RpcMountDeclaration<Schema> {
@@ -322,6 +324,8 @@ export type FormOptions<I, T> =
322
324
  readonly challenge?: FormChallengeOption
323
325
  /** The cron job each accepted submission kicks, in the background of the constant answer. */
324
326
  readonly then?: T
327
+ /** A page on another origin may submit it: `true` from the environment's listed origins, `'*'` from any. */
328
+ readonly crossOrigin?: true | '*'
325
329
  }
326
330
  & Owned<'kind' | 'path'>
327
331
 
@@ -435,6 +439,7 @@ function procedure<T extends ProcedureType, I extends InputSchema | null, O exte
435
439
  input: def.input ?? null,
436
440
  output: def.output,
437
441
  access: def.access,
442
+ ...(def.crossOrigin === undefined ? {} : { crossOrigin: def.crossOrigin }),
438
443
  unknownOptions: unknownOptions(def, 'procedure'),
439
444
  }
440
445
  }
@@ -488,6 +493,7 @@ export function surfaceKit<A extends string, D extends string, CD extends string
488
493
  honeypot: options.honeypot,
489
494
  challenge: options.challenge,
490
495
  then: options.then,
496
+ ...(options.crossOrigin === undefined ? {} : { crossOrigin: options.crossOrigin }),
491
497
  unknownOptions: unknownOptions(options, 'form'),
492
498
  }),
493
499
  webhook: (path, options) => ({
@@ -80,6 +80,8 @@ export type EndpointDef<I, O, Acc, S, U, Cn, Is, Ix extends AnyDbIndex> =
80
80
  readonly input?: I & NotLoose<I>
81
81
  readonly output: O & NotLoose<O>
82
82
  readonly access: Acc
83
+ /** A page on one of the environment's listed origins may call it, with a key (ADR-0022). An RPC endpoint only. */
84
+ readonly crossOrigin?: true
83
85
  }
84
86
  & DeclaredFields<S, U, Cn, Is, Ix>
85
87
 
@@ -336,6 +336,7 @@ interface EndpointOptions<Access> extends DeclaredDeclaration {
336
336
  readonly input?: Schema | null
337
337
  readonly output: Schema
338
338
  readonly access: Access
339
+ readonly crossOrigin?: true
339
340
  }
340
341
 
341
342
  function endpoint<Access>(type: ProcedureType, def: EndpointOptions<Access>): PendingSite<ProcedureDeclaration<Schema, Access>> {
@@ -345,6 +346,7 @@ function endpoint<Access>(type: ProcedureType, def: EndpointOptions<Access>): Pe
345
346
  input: def.input ?? null,
346
347
  output: def.output,
347
348
  access: def.access,
349
+ ...(def.crossOrigin === undefined ? {} : { crossOrigin: def.crossOrigin }),
348
350
  declared: declaredOf(def),
349
351
  unknownOptions: kitUnknownOptions(def, 'procedure'),
350
352
  }
@@ -115,6 +115,11 @@ export interface TestKitOptions {
115
115
  * `mail_not_configured`.
116
116
  */
117
117
  readonly mail?: MemoryEmailSender | 'none'
118
+ /**
119
+ * The origins the environment lists for the contract's `crossOrigin` endpoints, as an operator sets them in
120
+ * Control; a page on one of them may call such an endpoint with a key. None when omitted.
121
+ */
122
+ readonly crossOrigins?: readonly string[]
118
123
  }
119
124
 
120
125
  export interface TestConnection {
@@ -515,7 +520,15 @@ export function bootTestKit<C extends AnyContract>(
515
520
  getJwks: () => jwks,
516
521
  }
517
522
 
518
- const proxy = endpointProxy({ artifact, appId, host, iam, issuer: TEST_ISSUER, delegations: membership ?? null })
523
+ const proxy = endpointProxy({
524
+ artifact,
525
+ appId,
526
+ host,
527
+ iam,
528
+ issuer: TEST_ISSUER,
529
+ delegations: membership ?? null,
530
+ ...(options.crossOrigins === undefined ? {} : { crossOrigins: options.crossOrigins }),
531
+ })
519
532
 
520
533
  // What requests leave running: a job a form's or a mutation's `then` kicked, or a failure's report.
521
534
  const background = new Set<Promise<void>>()
@@ -546,7 +559,10 @@ export function bootTestKit<C extends AnyContract>(
546
559
  for (const [header, value] of outcome.asserted) {
547
560
  headers.set(header, value)
548
561
  }
549
- return application.fetch(new Request(request, { headers }), exec)
562
+ const answer = await application.fetch(new Request(request, { headers }), exec)
563
+ const decorated = new Headers(answer.headers)
564
+ outcome.decorate(decorated)
565
+ return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: decorated })
550
566
  }
551
567
 
552
568
  function browser(caller?: TestCaller): RpcFetch {
@@ -12,10 +12,12 @@ import {
12
12
  FORWARDED_PROTO_HEADER,
13
13
  FORWARDED_URI_HEADER,
14
14
  type IamGateway,
15
+ PREFLIGHT_HEADER,
15
16
  PROXY_TOKEN_HEADER,
16
17
  REQUEST_ID_HEADER,
17
18
  type TokenCache,
18
19
  } from '@fabrika/proxy-core'
20
+ import { applyCrossOriginHeaders, crossOriginHeaders } from '@fabrika/proxy-core/headers'
19
21
  import { contractProxyTable } from '@fabrika/proxy-core/manifest'
20
22
 
21
23
  /** What the proxy did with a request: answered it itself, or forwarded it with what only the proxy may set. */
@@ -26,6 +28,8 @@ export type ProxyOutcome =
26
28
  readonly asserted: ReadonlyMap<string, string>
27
29
  /** Whether the request's own `Authorization` reaches the application: not on an anonymous endpoint. */
28
30
  readonly keepsAuthorization: boolean
31
+ /** What the application's answer is given for an admitted cross-origin request, over its own headers. */
32
+ readonly decorate: (headers: Headers) => void
29
33
  }
30
34
  | { readonly outcome: 'answer'; readonly response: Response }
31
35
 
@@ -48,6 +52,8 @@ export interface EndpointProxyOptions {
48
52
  readonly delegations: DelegationIssuer | null
49
53
  /** The proxy's token cache, as production keeps one; omitted, every request mints. */
50
54
  readonly cache?: TokenCache
55
+ /** The origins the environment lists for the contract's cross-origin endpoints; none when omitted. */
56
+ readonly crossOrigins?: readonly string[]
51
57
  }
52
58
 
53
59
  const ASSERTED_HEADERS = [PROXY_TOKEN_HEADER, REQUEST_ID_HEADER, ENDPOINT_HEADER, DELEGATION_HEADER]
@@ -56,7 +62,14 @@ const ASSERTED_HEADERS = [PROXY_TOKEN_HEADER, REQUEST_ID_HEADER, ENDPOINT_HEADER
56
62
  export function endpointProxy(options: EndpointProxyOptions): TestProxy {
57
63
  const verify = createVerifyService({
58
64
  manifest: {
59
- apps: [{ id: options.appId, hosts: [options.host], upstream: 'app', scheme: 'https', ...contractProxyTable(options.artifact) }],
65
+ apps: [{
66
+ id: options.appId,
67
+ hosts: [options.host],
68
+ upstream: 'app',
69
+ scheme: 'https',
70
+ ...contractProxyTable(options.artifact),
71
+ ...(options.crossOrigins === undefined || options.crossOrigins.length === 0 ? {} : { crossOrigins: [...options.crossOrigins] }),
72
+ }],
60
73
  },
61
74
  iam: options.iam,
62
75
  issuer: options.issuer,
@@ -75,6 +88,12 @@ export function endpointProxy(options: EndpointProxyOptions): TestProxy {
75
88
  if (!verification.ok) {
76
89
  return { outcome: 'answer', response: verification }
77
90
  }
91
+ // An admitted preflight: the decision's 2xx is the whole answer, as both enforcement points return it.
92
+ if (verification.headers.get(PREFLIGHT_HEADER) === '1') {
93
+ verification.headers.delete(PREFLIGHT_HEADER)
94
+ return { outcome: 'answer', response: verification }
95
+ }
96
+ const allowOrigin = verification.headers.get('Access-Control-Allow-Origin')
78
97
  const asserted = new Map<string, string>()
79
98
  for (const header of ASSERTED_HEADERS) {
80
99
  const value = verification.headers.get(header)
@@ -82,6 +101,13 @@ export function endpointProxy(options: EndpointProxyOptions): TestProxy {
82
101
  asserted.set(header, value)
83
102
  }
84
103
  }
85
- return { outcome: 'forward', asserted, keepsAuthorization: asserted.has(PROXY_TOKEN_HEADER) }
104
+ return {
105
+ outcome: 'forward',
106
+ asserted,
107
+ keepsAuthorization: asserted.has(PROXY_TOKEN_HEADER),
108
+ decorate: (answer) => {
109
+ if (allowOrigin !== null) applyCrossOriginHeaders(answer, crossOriginHeaders(allowOrigin))
110
+ },
111
+ }
86
112
  }
87
113
  }