@fabrika/app 0.8.2 → 0.9.0-cloudflare.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -24
- package/skill/SKILL.md +29 -3
- package/skill/references/app-setup.md +29 -11
- package/skill/references/dev.md +2 -1
- package/skill/references/endpoint.md +12 -3
- package/skill/references/errors.md +39 -35
- package/skill/references/files.md +4 -2
- package/skill/references/jobs.md +9 -0
- package/skill/references/table-and-migration.md +19 -2
- package/skill/references/tests.md +33 -1
- package/skill/references/webhooks-links-connectors.md +1 -1
- package/src/build-recipe.ts +198 -0
- package/src/build.ts +60 -4
- package/src/bun.ts +33 -8
- package/src/cloudflare/application.ts +352 -0
- package/src/cloudflare/bindings.ts +159 -0
- package/src/cloudflare/names.ts +16 -0
- package/src/cloudflare/ops.ts +246 -0
- package/src/cloudflare/r2-files.ts +172 -0
- package/src/cloudflare.ts +99 -146
- package/src/contract/app-id.ts +5 -0
- package/src/contract/artifact-schema.ts +8 -1
- package/src/contract/build.ts +68 -11
- package/src/contract/compile.ts +1 -0
- package/src/contract/context.ts +9 -0
- package/src/contract/declaration.ts +2 -0
- package/src/contract/define.ts +28 -2
- package/src/contract/group.ts +10 -1
- package/src/contract/kit.ts +6 -2
- package/src/contract/platform.ts +3 -2
- package/src/contract/vocabulary.ts +26 -8
- package/src/dev/changes.ts +35 -0
- package/src/dev/contract-digest.ts +17 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +713 -0
- package/src/dev/output.ts +72 -0
- package/src/dev/wire.ts +136 -0
- package/src/disk-assets.ts +8 -3
- package/src/files/archive.ts +2 -2
- package/src/files/job-names.ts +12 -0
- package/src/files/kinds.ts +19 -7
- package/src/files/scan.ts +3 -4
- package/src/files/streams.ts +3 -1
- package/src/implement/anonymous.ts +11 -1
- package/src/implement/boot.ts +70 -7
- package/src/implement/bounded.ts +21 -2
- package/src/implement/capability.ts +18 -3
- package/src/implement/dispatch.ts +251 -147
- package/src/implement/egress.ts +341 -8
- package/src/implement/failures.ts +17 -1
- package/src/implement/faults.ts +1 -0
- package/src/implement/index.ts +1 -0
- package/src/implement/invocation.ts +18 -9
- package/src/implement/principal.ts +25 -2
- package/src/implement/terminals.ts +93 -0
- package/src/implement/workload.ts +24 -13
- package/src/jobs/report.ts +5 -1
- package/src/jobs/runner.ts +40 -19
- package/src/jobs/schedule.ts +7 -1
- package/src/jobs/scrub.ts +14 -1
- package/src/spa-fallback.ts +12 -0
- package/src/testing/endpoint-access.ts +307 -152
- package/src/testing/isolation.ts +8 -1
- package/src/testing/kit.ts +20 -2
- package/src/testing/proxy.ts +5 -5
- package/types/src/agent-skill.d.ts +29 -0
- package/types/src/build-recipe.d.ts +70 -0
- package/types/src/build.d.ts +29 -0
- package/types/src/bun.d.ts +66 -0
- package/types/src/client/caller.d.ts +19 -0
- package/types/src/client/contract.d.ts +131 -0
- package/types/src/client/error-reporting.d.ts +66 -0
- package/types/src/client/index.d.ts +4 -0
- package/types/src/client/rpc.d.ts +14 -0
- package/types/src/client/share.d.ts +2 -0
- package/types/src/client/transport.d.ts +42 -0
- package/types/src/cloudflare/application.d.ts +42 -0
- package/types/src/cloudflare/bindings.d.ts +33 -0
- package/types/src/cloudflare/names.d.ts +11 -0
- package/types/src/cloudflare/ops.d.ts +46 -0
- package/types/src/cloudflare/r2-files.d.ts +9 -0
- package/types/src/cloudflare.d.ts +42 -0
- package/types/src/contract/app-id.d.ts +2 -0
- package/types/src/contract/artifact-schema.d.ts +8 -0
- package/types/src/contract/attachment.d.ts +41 -0
- package/types/src/contract/build.d.ts +12 -0
- package/types/src/contract/compile.d.ts +11 -0
- package/types/src/contract/connect.d.ts +51 -0
- package/types/src/contract/context.d.ts +510 -0
- package/types/src/contract/declaration.d.ts +307 -0
- package/types/src/contract/defaults.d.ts +208 -0
- package/types/src/contract/define.d.ts +376 -0
- package/types/src/contract/file.d.ts +44 -0
- package/types/src/contract/group.d.ts +282 -0
- package/types/src/contract/handled.d.ts +18 -0
- package/types/src/contract/index.d.ts +19 -0
- package/types/src/contract/kit.d.ts +211 -0
- package/types/src/contract/platform.d.ts +9 -0
- package/types/src/contract/problems.d.ts +14 -0
- package/types/src/contract/redirect.d.ts +25 -0
- package/types/src/contract/schema-from-shape.d.ts +10 -0
- package/types/src/contract/schema-shape.d.ts +5 -0
- package/types/src/contract/snapshot-source.d.ts +15 -0
- package/types/src/contract/token.d.ts +58 -0
- package/types/src/contract/vocabulary.d.ts +32 -0
- package/types/src/contract/webhook-binding.d.ts +24 -0
- package/types/src/contract/webhook-scheme.d.ts +23 -0
- package/types/src/dev/changes.d.ts +7 -0
- package/types/src/dev/contract-digest.d.ts +1 -0
- package/types/src/dev/index.d.ts +2 -0
- package/types/src/dev/launcher.d.ts +32 -0
- package/types/src/dev/output.d.ts +28 -0
- package/types/src/dev/wire.d.ts +126 -0
- package/types/src/disk-assets.d.ts +6 -0
- package/types/src/errors.d.ts +46 -0
- package/types/src/fetch.d.ts +7 -0
- package/types/src/files/archive.d.ts +26 -0
- package/types/src/files/clamscan.d.ts +32 -0
- package/types/src/files/handle.d.ts +106 -0
- package/types/src/files/job-names.d.ts +9 -0
- package/types/src/files/kinds.d.ts +101 -0
- package/types/src/files/scan.d.ts +78 -0
- package/types/src/files/sniff.d.ts +7 -0
- package/types/src/files/streams.d.ts +17 -0
- package/types/src/files/treatment.d.ts +18 -0
- package/types/src/from-row.d.ts +20 -0
- package/types/src/honeypot.d.ts +2 -0
- package/types/src/implement/anonymous.d.ts +28 -0
- package/types/src/implement/attachment.d.ts +14 -0
- package/types/src/implement/boot.d.ts +120 -0
- package/types/src/implement/bounded.d.ts +32 -0
- package/types/src/implement/browser-config.d.ts +19 -0
- package/types/src/implement/capability.d.ts +144 -0
- package/types/src/implement/challenge.d.ts +75 -0
- package/types/src/implement/client-address.d.ts +11 -0
- package/types/src/implement/connect.d.ts +67 -0
- package/types/src/implement/crashes.d.ts +31 -0
- package/types/src/implement/dispatch.d.ts +221 -0
- package/types/src/implement/egress.d.ts +49 -0
- package/types/src/implement/environment.d.ts +14 -0
- package/types/src/implement/failures.d.ts +39 -0
- package/types/src/implement/faults.d.ts +45 -0
- package/types/src/implement/headers.d.ts +7 -0
- package/types/src/implement/implement.d.ts +58 -0
- package/types/src/implement/index.d.ts +25 -0
- package/types/src/implement/invocation.d.ts +125 -0
- package/types/src/implement/membership.d.ts +201 -0
- package/types/src/implement/multipart.d.ts +36 -0
- package/types/src/implement/params.d.ts +7 -0
- package/types/src/implement/principal.d.ts +57 -0
- package/types/src/implement/project.d.ts +11 -0
- package/types/src/implement/public.d.ts +61 -0
- package/types/src/implement/serve.d.ts +25 -0
- package/types/src/implement/terminals.d.ts +33 -0
- package/types/src/implement/tokens.d.ts +49 -0
- package/types/src/implement/types.d.ts +243 -0
- package/types/src/implement/urls.d.ts +15 -0
- package/types/src/implement/verifiers.d.ts +19 -0
- package/types/src/implement/webhook.d.ts +55 -0
- package/types/src/implement/workload.d.ts +56 -0
- package/types/src/implement/xlsx.d.ts +6 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/jobs/exception.d.ts +40 -0
- package/types/src/jobs/mail.d.ts +67 -0
- package/types/src/jobs/report.d.ts +56 -0
- package/types/src/jobs/runner.d.ts +154 -0
- package/types/src/jobs/schedule.d.ts +17 -0
- package/types/src/jobs/scrub.d.ts +12 -0
- package/types/src/jobs/types.d.ts +98 -0
- package/types/src/react/caller.d.ts +30 -0
- package/types/src/react/errors.d.ts +40 -0
- package/types/src/react/hooks.d.ts +110 -0
- package/types/src/react/index.d.ts +5 -0
- package/types/src/react/provider.d.ts +58 -0
- package/types/src/react/store.d.ts +68 -0
- package/types/src/react/testing.d.ts +28 -0
- package/types/src/react/types.d.ts +33 -0
- package/types/src/rpc/builder.d.ts +52 -0
- package/types/src/rpc/contract.d.ts +23 -0
- package/types/src/rpc/dispatcher.d.ts +11 -0
- package/types/src/rpc/types.d.ts +62 -0
- package/types/src/service/define.d.ts +42 -0
- package/types/src/service/index.d.ts +3 -0
- package/types/src/service/serve.d.ts +28 -0
- package/types/src/spa-fallback.d.ts +2 -0
- package/types/src/standard-schema.d.ts +55 -0
- package/types/src/testing/caller.d.ts +95 -0
- package/types/src/testing/challenge.d.ts +11 -0
- package/types/src/testing/dev.d.ts +97 -0
- package/types/src/testing/endpoint-access.d.ts +131 -0
- package/types/src/testing/index.d.ts +12 -0
- package/types/src/testing/isolation.d.ts +3 -0
- package/types/src/testing/kit.d.ts +187 -0
- package/types/src/testing/membership.d.ts +132 -0
- package/types/src/testing/policy-isolation.d.ts +113 -0
- package/types/src/testing/preload.d.ts +1 -0
- package/types/src/testing/proxy.d.ts +40 -0
- package/types/src/testing/runner.d.ts +8 -0
- package/types/src/testing/sample-file.d.ts +5 -0
- package/types/src/testing/status.d.ts +4 -0
- package/types/src/undumpable.d.ts +5 -0
package/src/cloudflare.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
// ─── Cloudflare Workers adapter ──────────────────────────────────────
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
type ApplicationObjects,
|
|
5
|
+
FABRIKA_APPLICATION_OBJECT,
|
|
6
|
+
FABRIKA_APPLICATION_OBJECTS,
|
|
7
|
+
FABRIKA_PROXY_SECRET,
|
|
8
|
+
OPS_PREFIX,
|
|
9
|
+
opsRefusal,
|
|
10
|
+
parseApplicationObjects,
|
|
11
|
+
parseOpsOperation,
|
|
12
|
+
PROXY_SECRET_HEADER,
|
|
13
|
+
} from '@fabrika/auth-core'
|
|
14
|
+
import { type ContractWorkerOptions, type ImplementationLoader, registerApplication, TICK_HEADER } from './cloudflare/application.js'
|
|
15
|
+
import { method } from './cloudflare/bindings.js'
|
|
16
|
+
import { CLOUDFLARE_APPLICATION_BINDING } from './cloudflare/names.js'
|
|
17
|
+
import { jsonBody, objectNamed, opsAccess, opsAuthenticated } from './cloudflare/ops.js'
|
|
6
18
|
import type { FetchService, RequestExecutionContext } from './fetch.js'
|
|
7
|
-
import { boot, type ContractApp, type IamKeySource } from './implement/boot.js'
|
|
8
|
-
import { CONNECT_BINDING_PREFIX, type ConnectFetch } from './implement/connect.js'
|
|
9
|
-
import { type CrashEvents, environmentCrashReporter, reportSurvivedCrashes } from './implement/crashes.js'
|
|
10
|
-
import type { SpaAssets } from './implement/dispatch.js'
|
|
11
|
-
import { captureEnvironment } from './implement/environment.js'
|
|
12
|
-
import type { ContractImplementation } from './implement/implement.js'
|
|
13
19
|
|
|
14
20
|
export { markProductionProcess } from '@fabrika/db/production'
|
|
21
|
+
export {
|
|
22
|
+
type ApplicationMigration,
|
|
23
|
+
type ApplicationState,
|
|
24
|
+
type ApplicationStorage,
|
|
25
|
+
type ContractWorkerOptions,
|
|
26
|
+
FabrikaApplication,
|
|
27
|
+
type ImplementationLoader,
|
|
28
|
+
} from './cloudflare/application.js'
|
|
15
29
|
|
|
16
30
|
/** Minimal structural view of a Workers `ExecutionContext`. */
|
|
17
31
|
export interface CloudflareExecutionContext extends RequestExecutionContext {
|
|
@@ -58,166 +72,105 @@ export function createCloudflareWorker<Env>(
|
|
|
58
72
|
|
|
59
73
|
// ─── Contract applications ───────────────────────────────────────────
|
|
60
74
|
|
|
61
|
-
/** The
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
68
|
-
const found: unknown = Reflect.get(target, name)
|
|
69
|
-
return typeof found === 'function' ? (...args) => Reflect.apply(found, target, args) : null
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function bindingKeySource(binding: unknown): IamKeySource {
|
|
73
|
-
const getJwks = method(binding, 'getJwks')
|
|
74
|
-
if (getJwks === null) {
|
|
75
|
-
throw new Error('the IAM binding is missing (env.IAM); the platform binds it to every application Worker')
|
|
76
|
-
}
|
|
77
|
-
return {
|
|
78
|
-
async getJwks() {
|
|
79
|
-
const jwks = parseJwks(await getJwks())
|
|
80
|
-
if (jwks === null) {
|
|
81
|
-
throw new Error('IAM returned no usable key set')
|
|
82
|
-
}
|
|
83
|
-
return jwks
|
|
84
|
-
},
|
|
75
|
+
/** The environment's proxy secret, which every request the Worker forwards to its object carries. */
|
|
76
|
+
function proxySecretOf(env: Readonly<Record<string, unknown>>): string {
|
|
77
|
+
const secret = env[FABRIKA_PROXY_SECRET]
|
|
78
|
+
if (typeof secret !== 'string' || secret === '') {
|
|
79
|
+
throw new Error(`${FABRIKA_PROXY_SECRET} is not set; the application's Worker forwards nothing to its object without it`)
|
|
85
80
|
}
|
|
81
|
+
return secret
|
|
86
82
|
}
|
|
87
83
|
|
|
88
84
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
85
|
+
* The object of ordinary traffic and the objects an ops request may name, all in the environment's own namespace.
|
|
86
|
+
* Without either variable (an older deploy) the one object is `application`.
|
|
91
87
|
*/
|
|
92
|
-
function
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
function applicationObjects(env: Readonly<Record<string, unknown>>): ApplicationObjects {
|
|
89
|
+
const object = env[FABRIKA_APPLICATION_OBJECT]
|
|
90
|
+
const objects = env[FABRIKA_APPLICATION_OBJECTS]
|
|
91
|
+
const parsed = parseApplicationObjects(typeof object === 'string' ? object : undefined, typeof objects === 'string' ? objects : undefined)
|
|
92
|
+
if (parsed === null) {
|
|
93
|
+
throw new Error(`${FABRIKA_APPLICATION_OBJECT} and ${FABRIKA_APPLICATION_OBJECTS} name no valid set of objects; a deploy writes both`)
|
|
96
94
|
}
|
|
97
|
-
return
|
|
98
|
-
const call = method(binding, name)
|
|
99
|
-
if (call === null) {
|
|
100
|
-
throw new Error(`the IAM binding has no ${name}`)
|
|
101
|
-
}
|
|
102
|
-
return await call(input)
|
|
103
|
-
})
|
|
95
|
+
return parsed
|
|
104
96
|
}
|
|
105
97
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
/** The object `name` of the environment's namespace (`FABRIKA_APPLICATION`), as a fetch. */
|
|
99
|
+
function applicationObject(env: Readonly<Record<string, unknown>>, name: string): (request: Request) => Promise<Response> {
|
|
100
|
+
const namespace = env[CLOUDFLARE_APPLICATION_BINDING]
|
|
101
|
+
const idFromName = method(namespace, 'idFromName')
|
|
102
|
+
const get = method(namespace, 'get')
|
|
103
|
+
if (idFromName === null || get === null) {
|
|
104
|
+
throw new Error(`the ${CLOUDFLARE_APPLICATION_BINDING} binding is missing; the provider binds the application's Durable Object to its Worker`)
|
|
110
105
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
106
|
+
const stub: unknown = get(idFromName(name))
|
|
107
|
+
// Read, not tested with `in`: a stub answers `fetch` without owning the property.
|
|
108
|
+
const fetchObject: unknown = typeof stub === 'object' && stub !== null ? Reflect.get(stub, 'fetch') : undefined
|
|
109
|
+
if (typeof fetchObject !== 'function') {
|
|
110
|
+
throw new Error(`the ${CLOUDFLARE_APPLICATION_BINDING} binding answered no object`)
|
|
111
|
+
}
|
|
112
|
+
return async (request) => {
|
|
113
|
+
const response: unknown = await Reflect.apply(fetchObject, stub, [request])
|
|
114
|
+
if (!(response instanceof Response)) {
|
|
115
|
+
throw new Error("the application's object answered something that is not a Response")
|
|
116
|
+
}
|
|
117
|
+
return response
|
|
119
118
|
}
|
|
120
119
|
}
|
|
121
120
|
|
|
122
121
|
/**
|
|
123
|
-
*
|
|
124
|
-
* the
|
|
125
|
-
* carries the owner's host, which the proxy Worker selects the application by.
|
|
122
|
+
* An ops request reaches the object its body names, when the environment lists it (ADR-0069 §1). Every refusal here
|
|
123
|
+
* is the same answer the object gives, and the object checks everything again.
|
|
126
124
|
*/
|
|
127
|
-
function
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
: undefined
|
|
134
|
-
if (typeof fetchOwner === 'function') {
|
|
135
|
-
bindings[name] = async (request) => {
|
|
136
|
-
const response: unknown = await Reflect.apply(fetchOwner, binding, [request])
|
|
137
|
-
if (!(response instanceof Response)) {
|
|
138
|
-
throw new Error(`the ${name} binding answered something that is not a Response`)
|
|
139
|
-
}
|
|
140
|
-
return response
|
|
141
|
-
}
|
|
142
|
-
}
|
|
125
|
+
async function forwardOps(request: Request, env: Readonly<Record<string, unknown>>): Promise<Response> {
|
|
126
|
+
const access = opsAccess(env)
|
|
127
|
+
// Receiver authentication of Control's key, as the object runs it; no enforcement decision. It runs before the body is
|
|
128
|
+
// read or any object looked up, so an unauthenticated caller wakes no object and learns nothing of the names.
|
|
129
|
+
if (access === null || !opsAuthenticated(request, access)) {
|
|
130
|
+
return opsRefusal()
|
|
143
131
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
export interface ContractWorkerOptions {
|
|
151
|
-
/** Where the isolate hears of an unhandled rejection; the global `unhandledrejection` event by default. */
|
|
152
|
-
readonly rejections?: CrashEvents
|
|
153
|
-
/** What posts a report to Operations' ingest; `fetch` by default. */
|
|
154
|
-
readonly operationsFetch?: (request: Request) => Promise<Response>
|
|
132
|
+
const operation = parseOpsOperation(new URL(request.url).pathname)
|
|
133
|
+
const object = operation === null ? null : objectNamed(operation, await jsonBody(request.clone()))
|
|
134
|
+
if (object === null || !access.objects.objects.includes(object)) {
|
|
135
|
+
return opsRefusal()
|
|
136
|
+
}
|
|
137
|
+
return await applicationObject(env, object)(request)
|
|
155
138
|
}
|
|
156
139
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
listen(listener) {
|
|
161
|
-
const rejection = (event: Event) => listener('unhandled_rejection', Reflect.get(event, 'reason'))
|
|
162
|
-
globalThis.addEventListener('unhandledrejection', rejection)
|
|
163
|
-
return () => globalThis.removeEventListener('unhandledrejection', rejection)
|
|
164
|
-
},
|
|
165
|
-
}
|
|
140
|
+
export interface ContractWorker {
|
|
141
|
+
fetch(request: Request, env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<Response>
|
|
142
|
+
scheduled(controller: CloudflareScheduledController, env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<void>
|
|
166
143
|
}
|
|
167
144
|
|
|
168
145
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
146
|
+
* The Worker module of a contract application, called only by the generated entrypoint, which also exports
|
|
147
|
+
* `FabrikaApplication`. The application runs in the environment's Durable Object (ADR-0065): `fetch` forwards every
|
|
148
|
+
* request to it with the proxy secret, the proxy's own headers unchanged, and `scheduled` forwards the minute tick.
|
|
149
|
+
* An ops request goes to the object its body names instead (ADR-0069). The object captures the environment, imports
|
|
150
|
+
* the application and boots it.
|
|
173
151
|
*/
|
|
174
|
-
export function createContractWorker(load: ImplementationLoader, options: ContractWorkerOptions = {}): {
|
|
175
|
-
|
|
176
|
-
} {
|
|
177
|
-
const booted = new WeakMap<object, Promise<ContractApp>>()
|
|
178
|
-
let bootFailureReported = false
|
|
179
|
-
let watchingRejections = false
|
|
180
|
-
const bootFor = async (env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<ContractApp> => {
|
|
181
|
-
const variables = Object.fromEntries(Object.entries(env).filter(([name]) => !FRAMEWORK_BINDINGS.has(name)))
|
|
182
|
-
const environment = captureEnvironment(variables)
|
|
183
|
-
const crashes = environmentCrashReporter(environment, options.operationsFetch)
|
|
184
|
-
let contractApp: ContractApp
|
|
185
|
-
try {
|
|
186
|
-
const { default: app } = await load()
|
|
187
|
-
contractApp = boot(app, {
|
|
188
|
-
environment,
|
|
189
|
-
iam: bindingKeySource(env['IAM']),
|
|
190
|
-
assets: bindingAssets(env['ASSETS']),
|
|
191
|
-
membership: bindingMembership(env['IAM']),
|
|
192
|
-
connectBindings: connectBindings(env),
|
|
193
|
-
// The proxy Worker sets `X-Fabrika-Client-Ip` from `CF-Connecting-IP`, the visitor Cloudflare saw.
|
|
194
|
-
clientAddress: 'vouched',
|
|
195
|
-
...(options.operationsFetch === undefined ? {} : { operationsFetch: options.operationsFetch }),
|
|
196
|
-
})
|
|
197
|
-
} catch (error) {
|
|
198
|
-
// Every request of a failed isolate boots again; one report says it.
|
|
199
|
-
if (crashes !== null && !bootFailureReported) {
|
|
200
|
-
bootFailureReported = true
|
|
201
|
-
exec.waitUntil(crashes.report('boot_failed', error))
|
|
202
|
-
}
|
|
203
|
-
throw error
|
|
204
|
-
}
|
|
205
|
-
if (crashes !== null && !watchingRejections) {
|
|
206
|
-
watchingRejections = true
|
|
207
|
-
reportSurvivedCrashes(options.rejections ?? globalRejectionEvents(), crashes)
|
|
208
|
-
}
|
|
209
|
-
return contractApp
|
|
210
|
-
}
|
|
152
|
+
export function createContractWorker(load: ImplementationLoader, options: ContractWorkerOptions = {}): ContractWorker {
|
|
153
|
+
registerApplication(load, options)
|
|
211
154
|
return {
|
|
212
|
-
async fetch(request, env
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
155
|
+
async fetch(request, env) {
|
|
156
|
+
const headers = new Headers(request.headers)
|
|
157
|
+
headers.delete(TICK_HEADER)
|
|
158
|
+
headers.set(PROXY_SECRET_HEADER, proxySecretOf(env))
|
|
159
|
+
const forwarded = new Request(request, { headers })
|
|
160
|
+
if (new URL(request.url).pathname.startsWith(OPS_PREFIX)) {
|
|
161
|
+
return await forwardOps(forwarded, env)
|
|
162
|
+
}
|
|
163
|
+
return await applicationObject(env, applicationObjects(env).object)(forwarded)
|
|
164
|
+
},
|
|
165
|
+
async scheduled(controller, env) {
|
|
166
|
+
const tick = new Request('https://application.invalid/', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { [PROXY_SECRET_HEADER]: proxySecretOf(env), [TICK_HEADER]: String(controller.scheduledTime) },
|
|
169
|
+
})
|
|
170
|
+
const response = await applicationObject(env, applicationObjects(env).object)(tick)
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new Error(`the application's object answered the minute tick with ${response.status}`)
|
|
219
173
|
}
|
|
220
|
-
return (await contractApp).fetch(request, exec)
|
|
221
174
|
},
|
|
222
175
|
}
|
|
223
176
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A leaf module: the contract (client side included) and the build recipe both read the id rule, and the contract must
|
|
2
|
+
// not reach the recipe's server-side modules through it.
|
|
3
|
+
|
|
4
|
+
/** The contract's application id rule; an id that holds to it reaches a generated script unquoted. */
|
|
5
|
+
export const CONTRACT_APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
@@ -14,16 +14,19 @@ import {
|
|
|
14
14
|
FILE_RESPONSE_KINDS,
|
|
15
15
|
isOwnWorkloadName,
|
|
16
16
|
JOB_MAILERS,
|
|
17
|
+
JOB_SITE_PREFIX,
|
|
17
18
|
LOCALES,
|
|
18
19
|
mailTemplateProblems,
|
|
19
20
|
MAX_CACHE_AGE_SECONDS,
|
|
20
21
|
type MessageKind,
|
|
21
22
|
type NumberShape,
|
|
22
23
|
type OutputShape,
|
|
24
|
+
PUBLIC_EGRESS,
|
|
23
25
|
type RecordShape,
|
|
24
26
|
type SchemaShape,
|
|
25
27
|
type StringShape,
|
|
26
28
|
type TablePolicyArtifact,
|
|
29
|
+
terminalIdentifyProblem,
|
|
27
30
|
type TupleShape,
|
|
28
31
|
} from '@fabrika/auth-core'
|
|
29
32
|
import { isPolicyOrigin, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
|
|
@@ -410,9 +413,12 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
|
|
|
410
413
|
kicks: z.array(name).nonempty().optional(),
|
|
411
414
|
binds: name.optional(),
|
|
412
415
|
signs: z.array(name).nonempty().optional(),
|
|
413
|
-
egress: z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty().optional(),
|
|
416
|
+
egress: z.union([z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty(), z.literal(PUBLIC_EGRESS)]).optional(),
|
|
414
417
|
})).superRefine((declared, context) => {
|
|
415
418
|
for (const [site, reach] of Object.entries(declared)) {
|
|
419
|
+
if (reach.egress === PUBLIC_EGRESS && !site.startsWith(JOB_SITE_PREFIX)) {
|
|
420
|
+
context.addIssue({ code: 'custom', path: [site, 'egress'], message: "egress: 'public' belongs to a cron job, keyed by job:<name>" })
|
|
421
|
+
}
|
|
416
422
|
if (reach.actions !== undefined && !isOwnWorkloadName('public', site)) {
|
|
417
423
|
context.addIssue({ code: 'custom', path: [site, 'actions'], message: 'workload actions belong to a public procedure, keyed by its endpoint id' })
|
|
418
424
|
}
|
|
@@ -425,6 +431,7 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
|
|
|
425
431
|
.optional(),
|
|
426
432
|
members: z.strictObject({ manage: action, list: action }).optional(),
|
|
427
433
|
directory: z.literal('labels').optional(),
|
|
434
|
+
terminal: z.strictObject({ identify: z.string().refine((path) => terminalIdentifyProblem(path) === null, 'not an identification path') }).optional(),
|
|
428
435
|
procedures: record(z.strictObject({
|
|
429
436
|
rate: z.strictObject({
|
|
430
437
|
perClient: bucket,
|
package/src/contract/build.ts
CHANGED
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
type CapabilityAccessForm,
|
|
14
14
|
type CapabilityRequirement,
|
|
15
15
|
type ChallengeProvider,
|
|
16
|
+
compileEndpointTable,
|
|
16
17
|
CONTRACT_FORMAT,
|
|
17
18
|
type ContractArtifact,
|
|
18
19
|
type CspExtensions,
|
|
19
20
|
type CspSource,
|
|
21
|
+
type DeclaredEgress,
|
|
20
22
|
type DeclaredReach,
|
|
21
23
|
type DimensionArtifact,
|
|
22
24
|
directoryReach,
|
|
@@ -48,18 +50,23 @@ import {
|
|
|
48
50
|
type MessageKind,
|
|
49
51
|
type MountArtifact,
|
|
50
52
|
type OutputShape,
|
|
53
|
+
planEndpoint,
|
|
51
54
|
type ProcedureArtifact,
|
|
52
55
|
type ProcedureChallenge,
|
|
56
|
+
PUBLIC_EGRESS,
|
|
53
57
|
type RoleArtifact,
|
|
54
58
|
type SchemaShape,
|
|
55
59
|
type ScopeBinding,
|
|
56
60
|
tableActions,
|
|
57
61
|
type TablePolicyArtifact,
|
|
62
|
+
type TerminalArtifact,
|
|
63
|
+
terminalIdentifyProblem,
|
|
58
64
|
type TokenPurposeArtifact,
|
|
59
65
|
type WebhookScheme,
|
|
60
66
|
} from '@fabrika/auth-core'
|
|
61
67
|
import { isPolicyOrigin, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
|
|
62
68
|
import { z } from 'zod/v4'
|
|
69
|
+
import { CONTRACT_APP_ID_PATTERN as APP_ID } from './app-id'
|
|
63
70
|
import { contractArtifactSchema } from './artifact-schema'
|
|
64
71
|
import {
|
|
65
72
|
type AccessDeclaration,
|
|
@@ -117,7 +124,6 @@ import { ContractError, Problems } from './problems'
|
|
|
117
124
|
import { TOKEN_MAX_DAYS } from './token'
|
|
118
125
|
import { hmacSchemeSchema, WEBHOOK_SCHEME_ALLOWED, webhookSchemeSchema } from './webhook-scheme'
|
|
119
126
|
|
|
120
|
-
const APP_ID = /^[a-z0-9][a-z0-9-]*$/
|
|
121
127
|
const NAME = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
122
128
|
const DIMENSION = /^[a-z][A-Za-z0-9_]*$/
|
|
123
129
|
const ACTION = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/
|
|
@@ -143,7 +149,7 @@ const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
|
|
|
143
149
|
/** The most origins one site calls: a site that reaches more is an integration hub, which a connector import serves. */
|
|
144
150
|
const MAX_EGRESS_ORIGINS = 16
|
|
145
151
|
const DECLARED_ALLOWED =
|
|
146
|
-
"sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', mailer: 'installation' | 'own', kicks: ['<cron job>'], binds: '<webhook>', signs: ['<token purpose>'], egress: ['https://<host>']"
|
|
152
|
+
"sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', mailer: 'installation' | 'own', kicks: ['<cron job>'], binds: '<webhook>', signs: ['<token purpose>'], egress: ['https://<host>'] | 'public' (a cron job)"
|
|
147
153
|
const CSP_FIELDS = ['connectSrc', 'imgSrc', 'frameAncestors', 'scriptSrc', 'frameSrc', 'styleSrc', 'fontSrc']
|
|
148
154
|
/** An environment variable an application reads; a challenge names its secret by one. */
|
|
149
155
|
const APPLICATION_VARIABLE = /^[A-Z][A-Z0-9_]*$/
|
|
@@ -234,6 +240,7 @@ class ArtifactBuilder {
|
|
|
234
240
|
const displayName = this.#displayName()
|
|
235
241
|
const mail = this.#mail()
|
|
236
242
|
const people = this.#people()
|
|
243
|
+
const terminal = this.#terminal()
|
|
237
244
|
const artifact: ContractArtifact = {
|
|
238
245
|
format: CONTRACT_FORMAT,
|
|
239
246
|
app: contract.app,
|
|
@@ -256,6 +263,7 @@ class ArtifactBuilder {
|
|
|
256
263
|
...(displayName === null ? {} : { displayName }),
|
|
257
264
|
...(mail === null ? {} : { mail }),
|
|
258
265
|
...people,
|
|
266
|
+
...(terminal === null ? {} : { terminal }),
|
|
259
267
|
...(Object.keys(this.#procedureRules).length === 0 ? {} : { procedures: this.#procedureRules }),
|
|
260
268
|
...(Object.keys(tokens).length === 0 ? {} : { tokens }),
|
|
261
269
|
}
|
|
@@ -263,10 +271,38 @@ class ArtifactBuilder {
|
|
|
263
271
|
assertNoAmbiguity(artifact)
|
|
264
272
|
} catch (error) {
|
|
265
273
|
this.#problem('endpoints', error instanceof Error ? error.message : String(error))
|
|
274
|
+
return artifact
|
|
275
|
+
}
|
|
276
|
+
if (terminal !== null) {
|
|
277
|
+
this.#checkIdentifyPage(artifact, terminal)
|
|
266
278
|
}
|
|
267
279
|
return artifact
|
|
268
280
|
}
|
|
269
281
|
|
|
282
|
+
/** The terminal's identification page, when its path is one. */
|
|
283
|
+
#terminal(): TerminalArtifact | null {
|
|
284
|
+
const terminal = this.#contract.terminal
|
|
285
|
+
if (terminal === undefined) return null
|
|
286
|
+
const problem = terminalIdentifyProblem(terminal.identify)
|
|
287
|
+
if (problem !== null) {
|
|
288
|
+
this.#problem('terminal.identify', `'${terminal.identify}' ${problem}`)
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
return { identify: terminal.identify }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Nobody is signed in on the identification page, so a public SPA mount must serve it: under a credentialed one, the
|
|
296
|
+
* proxy would bounce the terminal's browser back to sign-in, which sends it to the page again.
|
|
297
|
+
*/
|
|
298
|
+
#checkIdentifyPage(artifact: ContractArtifact, terminal: TerminalArtifact): void {
|
|
299
|
+
const plan = planEndpoint(compileEndpointTable(artifact), 'GET', terminal.identify)
|
|
300
|
+
if (plan.outcome !== 'endpoint' || plan.endpoint.kind !== 'public-spa') {
|
|
301
|
+
const served = plan.outcome === 'endpoint' ? `'${plan.endpoint.id}' (${plan.endpoint.kind})` : 'no endpoint'
|
|
302
|
+
this.#problem('terminal.identify', `'${terminal.identify}' is served by ${served}; serve it from an spa mount with access: { public: true }`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
270
306
|
/** The member directory each endpoint reads, recorded so the approval and the digest cover the reach. */
|
|
271
307
|
#directories(dimensions: ContractArtifact['dimensions']): Record<string, EndpointDirectory> {
|
|
272
308
|
const directories: Record<string, EndpointDirectory> = {}
|
|
@@ -2072,7 +2108,7 @@ class ArtifactBuilder {
|
|
|
2072
2108
|
...(kicks.length === 0 ? {} : { kicks }),
|
|
2073
2109
|
...(binds === undefined ? {} : { binds }),
|
|
2074
2110
|
...(signs.length === 0 ? {} : { signs }),
|
|
2075
|
-
...(egress
|
|
2111
|
+
...(egress === null ? {} : { egress }),
|
|
2076
2112
|
}
|
|
2077
2113
|
if (place.kind !== 'rpc') {
|
|
2078
2114
|
this.#rpcOnly(where, 'connect', value['connect'])
|
|
@@ -2271,12 +2307,13 @@ class ArtifactBuilder {
|
|
|
2271
2307
|
|
|
2272
2308
|
/**
|
|
2273
2309
|
* The HTTPS origins a site's code calls: a credentialed procedure of an RPC mount, a webhook or a job, never a public,
|
|
2274
|
-
* export or link procedure. Each is an exact origin with no path or wildcard, once
|
|
2275
|
-
* a
|
|
2310
|
+
* export or link procedure. Each is an exact origin with no path or wildcard, once. Or `'public'`, every public host
|
|
2311
|
+
* (ADR-0070), which only a cron job declares: it calls outside any transaction, and its schedule, not a caller,
|
|
2312
|
+
* decides when. `null` when it declares none or after a problem.
|
|
2276
2313
|
*/
|
|
2277
|
-
#egress(where: string, value: unknown, place: DeclaringSite):
|
|
2314
|
+
#egress(where: string, value: unknown, place: DeclaringSite): DeclaredEgress | null {
|
|
2278
2315
|
if (value === undefined) {
|
|
2279
|
-
return
|
|
2316
|
+
return null
|
|
2280
2317
|
}
|
|
2281
2318
|
const refusedBy = place.kind === 'export'
|
|
2282
2319
|
? 'an export procedure'
|
|
@@ -2287,15 +2324,35 @@ class ArtifactBuilder {
|
|
|
2287
2324
|
: null
|
|
2288
2325
|
if (refusedBy !== null) {
|
|
2289
2326
|
this.#problem(where, `declares egress, which ${refusedBy} never does: a credentialed procedure, a webhook or a job calls out`)
|
|
2290
|
-
return
|
|
2327
|
+
return null
|
|
2328
|
+
}
|
|
2329
|
+
if (value === PUBLIC_EGRESS) {
|
|
2330
|
+
if (place.kind !== 'job') {
|
|
2331
|
+
this.#problem(
|
|
2332
|
+
where,
|
|
2333
|
+
`declares egress: '${PUBLIC_EGRESS}', which only a cron job declares: a procedure or a webhook names its origins (allowed: egress: ['https://api.example.com'])`,
|
|
2334
|
+
)
|
|
2335
|
+
return null
|
|
2336
|
+
}
|
|
2337
|
+
return PUBLIC_EGRESS
|
|
2291
2338
|
}
|
|
2292
2339
|
if (!Array.isArray(value) || value.length === 0 || !value.every((origin) => typeof origin === 'string')) {
|
|
2293
|
-
this.#problem(
|
|
2294
|
-
|
|
2340
|
+
this.#problem(
|
|
2341
|
+
where,
|
|
2342
|
+
place.kind === 'job'
|
|
2343
|
+
? "egress is not a list of origins or 'public' (allowed: egress: ['https://api.example.com'] or egress: 'public')"
|
|
2344
|
+
: "egress is not a list of origins (allowed: egress: ['https://api.example.com'])",
|
|
2345
|
+
)
|
|
2346
|
+
return null
|
|
2295
2347
|
}
|
|
2296
2348
|
const origins: string[] = value.filter((origin) => typeof origin === 'string')
|
|
2297
2349
|
if (origins.length > MAX_EGRESS_ORIGINS) {
|
|
2298
|
-
this.#problem(
|
|
2350
|
+
this.#problem(
|
|
2351
|
+
where,
|
|
2352
|
+
`${origins.length} egress origins exceed the limit of ${MAX_EGRESS_ORIGINS}${
|
|
2353
|
+
place.kind === 'job' ? " (a cron job that calls the websites its operators name declares egress: 'public')" : ''
|
|
2354
|
+
}`,
|
|
2355
|
+
)
|
|
2299
2356
|
}
|
|
2300
2357
|
for (const origin of origins) {
|
|
2301
2358
|
if (!isPolicyOrigin(origin)) {
|
package/src/contract/compile.ts
CHANGED
|
@@ -81,6 +81,7 @@ export function describeContract(contract: AnyContract): ContractDeclaration<Sch
|
|
|
81
81
|
...(vocabulary.mail === undefined ? {} : { mail: vocabulary.mail }),
|
|
82
82
|
...(vocabulary.members === undefined ? {} : { members: vocabulary.members }),
|
|
83
83
|
...(vocabulary.directory === undefined ? {} : { directory: vocabulary.directory }),
|
|
84
|
+
...(vocabulary.terminal === undefined ? {} : { terminal: { identify: vocabulary.terminal.identify } }),
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
package/src/contract/context.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { StoredFilesHandle, StoredFilesReadHandle } from '../files/handle'
|
|
|
25
25
|
import type { AppCapability, Ref, Within } from '../implement/capability'
|
|
26
26
|
import type { ApplicationDirectory, LinkIssuing, MemberDirectory, MemberLookup, MemberManagement } from '../implement/membership'
|
|
27
27
|
import type { AppPrincipal, PrincipalOf, PublicPrincipal, ServicePrincipal } from '../implement/principal'
|
|
28
|
+
import type { TerminalIdentification } from '../implement/terminals'
|
|
28
29
|
import type { CapabilityOf, ConnectCall, RequestContext } from '../implement/types'
|
|
29
30
|
import type { WebhookDelivery, WebhookPrincipal } from '../implement/webhook'
|
|
30
31
|
import type { AppMail, JobContext } from '../jobs/types'
|
|
@@ -56,6 +57,8 @@ export interface KitTypes {
|
|
|
56
57
|
readonly dimensions: unknown
|
|
57
58
|
/** The actions of the application's own people, as written; `never` when the vocabulary declares none. */
|
|
58
59
|
readonly members: { readonly manage: string; readonly list: string }
|
|
60
|
+
/** Whether the vocabulary declares `terminal: { identify }`, which gives `ctx.terminals`. */
|
|
61
|
+
readonly terminal: boolean
|
|
59
62
|
readonly index: AnyDbIndex
|
|
60
63
|
/** The table names of `src/db/tables.ts`. */
|
|
61
64
|
readonly tables: string
|
|
@@ -357,6 +360,10 @@ type ConnectContext<K extends KitTypes, Cn extends readonly string[]> = [Cn] ext
|
|
|
357
360
|
? { readonly connect: Unavailable<"declare connect: ['<connector>'] on this endpoint to call another application"> }
|
|
358
361
|
: { readonly connect: { readonly [N in Cn[number]]: ConnectorOf<K['imports'][N]> } }
|
|
359
362
|
|
|
363
|
+
/** `ctx.terminals`, where the vocabulary declares the terminal identification page. */
|
|
364
|
+
type TerminalsContext<K extends KitTypes> = K['terminal'] extends true ? { readonly terminals: TerminalIdentification }
|
|
365
|
+
: { readonly terminals: Unavailable<"the application declares no terminal: terminal: { identify: '/identify' } in the vocabulary"> }
|
|
366
|
+
|
|
360
367
|
/** What the handler of an RPC endpoint receives, for mount callers `Caller`, kind `Kind` and its declaration. */
|
|
361
368
|
export type EndpointContext<
|
|
362
369
|
K extends KitTypes,
|
|
@@ -370,6 +377,7 @@ export type EndpointContext<
|
|
|
370
377
|
& MembersContext<K, Acc>
|
|
371
378
|
& LinksContext<Acc, Of<D, 'issues'>>
|
|
372
379
|
& ConnectContext<K, Of<D, 'connect'>>
|
|
380
|
+
& TerminalsContext<K>
|
|
373
381
|
& SqlContext<Of<D, 'sql'>, K['index']>
|
|
374
382
|
& FilesContext<K['index'], Kind>
|
|
375
383
|
& { readonly db: DataOf<K['index'], EndpointData<K['index'], Kind, Acc, Of<D, 'unscoped'>>> }
|
|
@@ -396,6 +404,7 @@ export type PublicContext<K extends KitTypes, Kind extends DataKind, Acts extend
|
|
|
396
404
|
readonly files: Kind extends 'query' ? StoredFilesReadHandle : StoredFilesHandle
|
|
397
405
|
readonly sql: Unavailable<'a public procedure reaches its actions only: it declares no sql'>
|
|
398
406
|
}
|
|
407
|
+
& TerminalsContext<K>
|
|
399
408
|
& {
|
|
400
409
|
readonly db: DataOf<
|
|
401
410
|
K['index'],
|
|
@@ -26,6 +26,8 @@ export interface ContractDeclaration<S> {
|
|
|
26
26
|
readonly members?: { readonly manage: string; readonly list: string }
|
|
27
27
|
/** `'labels'` only; `buildArtifact` refuses another value, and refuses it without `members`. */
|
|
28
28
|
readonly directory?: string
|
|
29
|
+
/** The identification page of a terminal; `buildArtifact` refuses a path a public SPA mount does not serve. */
|
|
30
|
+
readonly terminal?: { readonly identify: string }
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
package/src/contract/define.ts
CHANGED
|
@@ -26,6 +26,11 @@ import type { TokenPurposes } from './token'
|
|
|
26
26
|
|
|
27
27
|
export type Path = `/${string}`
|
|
28
28
|
|
|
29
|
+
/** Whether `value` is written as a `Path`; what `buildArtifact` refuses of one is a separate check. */
|
|
30
|
+
export function isPath(value: string): value is Path {
|
|
31
|
+
return value.startsWith('/')
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
/** A job attempt's time limit: 15 minutes by default, at most a day. A job reads no body, so it has no `maxBytes`. */
|
|
30
35
|
export interface JobLimitsOption {
|
|
31
36
|
readonly timeoutMs?: number
|
|
@@ -92,7 +97,7 @@ export interface RoleDef<A extends string, D extends string, R extends string> {
|
|
|
92
97
|
readonly external?: true
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
export interface Vocabulary<A extends string, D extends string, R extends string> extends Presentation, AppPeople<A
|
|
100
|
+
export interface Vocabulary<A extends string, D extends string, R extends string> extends Presentation, AppPeople<A>, AppTerminal {
|
|
96
101
|
readonly app: string
|
|
97
102
|
readonly dimensions: { readonly [K in D]: DimensionDef<A, D, R> }
|
|
98
103
|
readonly actions: { readonly [K in A]: string }
|
|
@@ -124,6 +129,26 @@ export function peopleOf<A extends string>(vocabulary: AppPeople<A>): AppPeople<
|
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
/** The page that identifies a person at an enrolled terminal (ADR-0067). */
|
|
133
|
+
export interface TerminalDeclaration {
|
|
134
|
+
/** An absolute path on the application's own origin, served by a public SPA mount; IAM appends `?identification=<id>`. */
|
|
135
|
+
readonly identify: Path
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `terminal: { identify: '/identify' }`: the application identifies a person at an enrolled terminal on that page and
|
|
140
|
+
* asserts who stands there (`ctx.terminals`). IAM sends a terminal's browser there only for an application its
|
|
141
|
+
* administrator lists on the terminal. A reviewed declaration.
|
|
142
|
+
*/
|
|
143
|
+
export interface AppTerminal {
|
|
144
|
+
readonly terminal?: TerminalDeclaration
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A vocabulary's terminal, copied field by field: it exists only when declared. */
|
|
148
|
+
export function terminalOf(vocabulary: AppTerminal): AppTerminal {
|
|
149
|
+
return vocabulary.terminal === undefined ? {} : { terminal: { identify: vocabulary.terminal.identify } }
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
/** How IAM names the application to a person: on its sign-in, password and invitation pages, and in its e-mails. */
|
|
128
153
|
export interface Presentation {
|
|
129
154
|
/** The language of those pages and e-mails; absent, the installation's default. */
|
|
@@ -535,7 +560,7 @@ export function defineContract<
|
|
|
535
560
|
const Dims extends { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> },
|
|
536
561
|
const S extends Surface,
|
|
537
562
|
>(
|
|
538
|
-
vocabulary: Presentation & AppPeople<NoInfer<A>> & {
|
|
563
|
+
vocabulary: Presentation & AppPeople<NoInfer<A>> & AppTerminal & {
|
|
539
564
|
readonly app: string
|
|
540
565
|
readonly dimensions: Dims & { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> }
|
|
541
566
|
readonly actions: { readonly [K in A]: K extends `${string}*${string}` ? WildcardMessage : string }
|
|
@@ -551,6 +576,7 @@ export function defineContract<
|
|
|
551
576
|
roles: vocabulary.roles,
|
|
552
577
|
...presentationOf(vocabulary),
|
|
553
578
|
...peopleOf(vocabulary),
|
|
579
|
+
...terminalOf(vocabulary),
|
|
554
580
|
},
|
|
555
581
|
dimensions: vocabulary.dimensions,
|
|
556
582
|
surface: surface(surfaceKit()),
|