@fabrika/app 0.5.2 → 0.5.4
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 +9 -9
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +8 -8
- package/skill/references/endpoint.md +16 -0
- package/skill/references/public-intake.md +4 -0
- package/src/contract/artifact-schema.ts +11 -1
- package/src/contract/build.ts +56 -2
- package/src/contract/declaration.ts +6 -2
- package/src/contract/define.ts +6 -0
- package/src/contract/group.ts +2 -0
- package/src/contract/kit.ts +2 -0
- package/src/testing/kit.ts +18 -2
- package/src/testing/proxy.ts +28 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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.
|
|
97
|
-
"@fabrika/auth-core": "0.5.
|
|
98
|
-
"@fabrika/db": "0.5.
|
|
99
|
-
"@fabrika/email": "0.5.
|
|
100
|
-
"@fabrika/jwks": "0.5.
|
|
101
|
-
"@fabrika/platform": "0.5.
|
|
102
|
-
"@fabrika/platform-node": "0.5.
|
|
103
|
-
"@fabrika/proxy-core": "0.5.
|
|
96
|
+
"@fabrika/auth": "0.5.4",
|
|
97
|
+
"@fabrika/auth-core": "0.5.4",
|
|
98
|
+
"@fabrika/db": "0.5.4",
|
|
99
|
+
"@fabrika/email": "0.5.4",
|
|
100
|
+
"@fabrika/jwks": "0.5.4",
|
|
101
|
+
"@fabrika/platform": "0.5.4",
|
|
102
|
+
"@fabrika/platform-node": "0.5.4",
|
|
103
|
+
"@fabrika/proxy-core": "0.5.4",
|
|
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.
|
|
3
|
+
version: 0.5.4
|
|
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.
|
|
23
|
-
"@fabrika/db": "0.5.
|
|
24
|
-
"@fabrika/provider-zerops": "0.5.
|
|
25
|
-
"@fabrika/proxy-core": "0.5.
|
|
26
|
-
"@fabrika/ui": "0.5.
|
|
22
|
+
"@fabrika/app": "0.5.4",
|
|
23
|
+
"@fabrika/db": "0.5.4",
|
|
24
|
+
"@fabrika/provider-zerops": "0.5.4",
|
|
25
|
+
"@fabrika/proxy-core": "0.5.4",
|
|
26
|
+
"@fabrika/ui": "0.5.4",
|
|
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.
|
|
34
|
-
"@fabrika/cli": "0.5.
|
|
35
|
-
"@fabrika/platform": "0.5.
|
|
33
|
+
"@fabrika/check": "0.5.4",
|
|
34
|
+
"@fabrika/cli": "0.5.4",
|
|
35
|
+
"@fabrika/platform": "0.5.4",
|
|
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') }),
|
package/src/contract/build.ts
CHANGED
|
@@ -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(
|
|
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({
|
|
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 {
|
package/src/contract/define.ts
CHANGED
|
@@ -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) => ({
|
package/src/contract/group.ts
CHANGED
|
@@ -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
|
|
package/src/contract/kit.ts
CHANGED
|
@@ -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
|
}
|
package/src/testing/kit.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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 {
|
package/src/testing/proxy.ts
CHANGED
|
@@ -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: [{
|
|
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 {
|
|
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
|
}
|