@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/implement/egress.ts
CHANGED
|
@@ -1,23 +1,107 @@
|
|
|
1
|
-
// `egress`: while a site's handler runs, the global `fetch` reaches only the HTTPS origins the site declares
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `egress`: while a site's handler runs, the global `fetch` reaches only the HTTPS origins the site declares, or, for a
|
|
2
|
+
// cron job that declares `egress: 'public'`, only public hosts (ADR-0070). It stops a mistake and makes a bypass
|
|
3
|
+
// conspicuous; it is not a sandbox (ADR-0012 §8): code that kept the `fetch` it found before boot, or opens its own
|
|
4
|
+
// sockets, escapes it. The framework's own facilities (mail, connectors, IAM, the object store) run outside the site's
|
|
5
|
+
// scope, under their own declarations.
|
|
5
6
|
|
|
7
|
+
import {
|
|
8
|
+
type DeclaredEgress,
|
|
9
|
+
FABRIKA_EGRESS_REFUSED_HOSTS,
|
|
10
|
+
isPublicAddress,
|
|
11
|
+
normalizedHost,
|
|
12
|
+
PUBLIC_EGRESS,
|
|
13
|
+
publicEgressRefusal,
|
|
14
|
+
} from '@fabrika/auth-core'
|
|
6
15
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
7
16
|
import { CapabilityRefusedError } from './capability'
|
|
8
17
|
|
|
18
|
+
/** The addresses a host name resolves to, where the runtime exposes resolution: Bun's `node:dns`, never a Worker. */
|
|
19
|
+
export type HostResolver = (hostname: string) => Promise<readonly string[]>
|
|
20
|
+
|
|
21
|
+
/** What public egress refuses beyond what a URL alone shows (`publicEgressRefusal`). */
|
|
22
|
+
export interface PublicEgressRules {
|
|
23
|
+
/** The installation's own hosts, normalized: every host a framework variable names, and `FABRIKA_EGRESS_REFUSED_HOSTS`. */
|
|
24
|
+
readonly refusedHosts: ReadonlySet<string>
|
|
25
|
+
/** Resolves each hop's host before it is sent, so a public name of a private address is refused; `null` on Cloudflare. */
|
|
26
|
+
readonly resolve: HostResolver | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What one site's `fetch` reaches: its declared origins, or the rules of public egress. */
|
|
30
|
+
export type SiteEgress = readonly string[] | PublicEgressRules
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The bounds of one request under public egress, the redirects it follows included. The handler's own signal still
|
|
34
|
+
* aborts it sooner (`ctx.signal`, an `AbortSignal.timeout` of its own).
|
|
35
|
+
*/
|
|
36
|
+
export const PUBLIC_EGRESS_LIMITS = {
|
|
37
|
+
/** One request, its redirects and the reading of its body included: the abort also stops a body still streaming. */
|
|
38
|
+
timeoutMs: 60_000,
|
|
39
|
+
/** One response's body as the handler reads it; a larger one is refused, by its `Content-Length` or while it streams. */
|
|
40
|
+
maxResponseBytes: 16 * 1024 * 1024,
|
|
41
|
+
/** The redirects one request follows when its `redirect` mode is `follow`, each hop checked again. */
|
|
42
|
+
maxRedirects: 10,
|
|
43
|
+
} as const
|
|
44
|
+
|
|
9
45
|
interface EgressScope {
|
|
10
46
|
/** The site, as a refusal names it. */
|
|
11
47
|
readonly site: string
|
|
12
48
|
readonly origins: ReadonlySet<string>
|
|
49
|
+
/** Public egress: `null` for a site that declares origins, or none. */
|
|
50
|
+
readonly open: PublicEgressRules | null
|
|
13
51
|
}
|
|
14
52
|
|
|
15
53
|
const scope = new AsyncLocalStorage<EgressScope>()
|
|
16
54
|
const guards = new WeakSet<object>()
|
|
17
55
|
|
|
18
56
|
/** Runs a site's handler: every `fetch` it starts reaches only `egress`. */
|
|
19
|
-
export function runAsSite<T>(site: string, egress:
|
|
20
|
-
return
|
|
57
|
+
export function runAsSite<T>(site: string, egress: SiteEgress, work: () => Promise<T>): Promise<T> {
|
|
58
|
+
return isOrigins(egress)
|
|
59
|
+
? scope.run({ site, origins: new Set(egress), open: null }, work)
|
|
60
|
+
: scope.run({ site, origins: new Set(), open: egress }, work)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The egress a site runs with: its declared origins, or `rules` for `'public'`. Only a cron job's runner holds rules; a
|
|
65
|
+
* site run without them reaches nothing, though the build lets no other site declare `'public'`.
|
|
66
|
+
*/
|
|
67
|
+
export function siteEgress(declared: DeclaredEgress, rules: PublicEgressRules | null): SiteEgress {
|
|
68
|
+
return declared === PUBLIC_EGRESS ? (rules ?? []) : declared
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isOrigins(egress: SiteEgress): egress is readonly string[] {
|
|
72
|
+
return Array.isArray(egress)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The rules of public egress in one environment: the hosts its framework variables name (IAM, Operations, its own
|
|
77
|
+
* origin, the platform's receivers, the object store), the installation hosts Control hands it, and the resolver the
|
|
78
|
+
* runtime adapter supplies.
|
|
79
|
+
*/
|
|
80
|
+
export function publicEgressRules(framework: Readonly<Record<string, string>>, resolve: HostResolver | null): PublicEgressRules {
|
|
81
|
+
const hosts = new Set<string>()
|
|
82
|
+
for (const [name, value] of Object.entries(framework)) {
|
|
83
|
+
if (name === FABRIKA_EGRESS_REFUSED_HOSTS) {
|
|
84
|
+
for (const host of value.split(',')) {
|
|
85
|
+
const normalized = normalizedHost(host.trim())
|
|
86
|
+
if (normalized !== '') hosts.add(normalized)
|
|
87
|
+
}
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const host = urlHost(value)
|
|
91
|
+
if (host !== null) hosts.add(host)
|
|
92
|
+
}
|
|
93
|
+
return { refusedHosts: hosts, resolve }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The host of a framework variable's value when it is an http(s) URL, such as an origin or a DSN; `null` otherwise. */
|
|
97
|
+
function urlHost(value: string): string | null {
|
|
98
|
+
const text = value.trim()
|
|
99
|
+
if (!/^https?:\/\//i.test(text)) return null
|
|
100
|
+
try {
|
|
101
|
+
return normalizedHost(new URL(text).hostname)
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
21
105
|
}
|
|
22
106
|
|
|
23
107
|
/**
|
|
@@ -41,6 +125,8 @@ export function platformCall<T>(work: () => T): T {
|
|
|
41
125
|
return scope.exit(work)
|
|
42
126
|
}
|
|
43
127
|
|
|
128
|
+
type Fetch = (input: string, init: RequestInit) => Promise<Response>
|
|
129
|
+
|
|
44
130
|
/**
|
|
45
131
|
* Wraps the global `fetch` in the guard, once: a `fetch` that is already a guard stays. A test that replaces `fetch`
|
|
46
132
|
* after boot replaces the guard with it.
|
|
@@ -50,15 +136,20 @@ export function installEgressGuard(): void {
|
|
|
50
136
|
if (guards.has(current)) {
|
|
51
137
|
return
|
|
52
138
|
}
|
|
139
|
+
const send: Fetch = (input, init) => current(input, init)
|
|
53
140
|
const guarded = Object.assign(
|
|
54
141
|
(input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
55
142
|
const running = scope.getStore()
|
|
56
143
|
if (running === undefined) {
|
|
57
144
|
return current(input, init)
|
|
58
145
|
}
|
|
146
|
+
if (running.open !== null) {
|
|
147
|
+
return publicFetch(send, running.site, running.open, input, init)
|
|
148
|
+
}
|
|
59
149
|
const origin = originOf(input)
|
|
60
150
|
if (origin !== null && running.origins.has(origin)) {
|
|
61
|
-
|
|
151
|
+
// Only the standard fields: a runtime's own (Bun's `unix`, `proxy`, `tls`) would send the request elsewhere.
|
|
152
|
+
return current(input, standardInit(init))
|
|
62
153
|
}
|
|
63
154
|
return Promise.reject(
|
|
64
155
|
new CapabilityRefusedError(
|
|
@@ -69,12 +160,47 @@ export function installEgressGuard(): void {
|
|
|
69
160
|
),
|
|
70
161
|
)
|
|
71
162
|
},
|
|
72
|
-
{
|
|
163
|
+
{
|
|
164
|
+
// Inside a site's scope a preconnect reaches only a declared origin; public egress, which cannot check a hop
|
|
165
|
+
// before it connects, refuses every one.
|
|
166
|
+
preconnect: (...args: Parameters<typeof current.preconnect>): void => {
|
|
167
|
+
const running = scope.getStore()
|
|
168
|
+
if (running !== undefined) {
|
|
169
|
+
const origin = originOf(args[0])
|
|
170
|
+
if (running.open !== null || origin === null || !running.origins.has(origin)) {
|
|
171
|
+
throw new CapabilityRefusedError(
|
|
172
|
+
running.open === null ? 'undeclared_egress' : 'refused_egress',
|
|
173
|
+
`${running.site} preconnects to ${origin ?? 'a URL that is not absolute'}, which its egress does not reach`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (typeof current.preconnect === 'function') current.preconnect(...args)
|
|
178
|
+
},
|
|
179
|
+
},
|
|
73
180
|
)
|
|
74
181
|
guards.add(guarded)
|
|
75
182
|
globalThis.fetch = guarded
|
|
76
183
|
}
|
|
77
184
|
|
|
185
|
+
/** `init` with the fields of the Fetch standard only. */
|
|
186
|
+
function standardInit(init: RequestInit | undefined): RequestInit | undefined {
|
|
187
|
+
if (init === undefined) return undefined
|
|
188
|
+
return {
|
|
189
|
+
...(init.method === undefined ? {} : { method: init.method }),
|
|
190
|
+
...(init.headers === undefined ? {} : { headers: init.headers }),
|
|
191
|
+
...(init.body === undefined ? {} : { body: init.body }),
|
|
192
|
+
...(init.redirect === undefined ? {} : { redirect: init.redirect }),
|
|
193
|
+
...(init.signal === undefined ? {} : { signal: init.signal }),
|
|
194
|
+
...(init.keepalive === undefined ? {} : { keepalive: init.keepalive }),
|
|
195
|
+
...(init.integrity === undefined ? {} : { integrity: init.integrity }),
|
|
196
|
+
...(init.referrer === undefined ? {} : { referrer: init.referrer }),
|
|
197
|
+
...(init.referrerPolicy === undefined ? {} : { referrerPolicy: init.referrerPolicy }),
|
|
198
|
+
...(init.cache === undefined ? {} : { cache: init.cache }),
|
|
199
|
+
...(init.credentials === undefined ? {} : { credentials: init.credentials }),
|
|
200
|
+
...(init.mode === undefined ? {} : { mode: init.mode }),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
78
204
|
function originOf(input: string | URL | Request): string | null {
|
|
79
205
|
try {
|
|
80
206
|
return new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).origin
|
|
@@ -82,3 +208,210 @@ function originOf(input: string | URL | Request): string | null {
|
|
|
82
208
|
return null
|
|
83
209
|
}
|
|
84
210
|
}
|
|
211
|
+
|
|
212
|
+
const REDIRECTS: ReadonlySet<number> = new Set([301, 302, 303, 307, 308])
|
|
213
|
+
/** The headers a redirect that turns a request into a `GET` drops with its body (Fetch, "HTTP-redirect fetch"). */
|
|
214
|
+
const BODY_HEADERS = ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location']
|
|
215
|
+
/**
|
|
216
|
+
* The request headers public egress never sends, on any hop: the Fetch standard's forbidden request headers (`Host`
|
|
217
|
+
* among them, which would route a request for a public name to another site at the same address), the forwarding
|
|
218
|
+
* headers a proxy trusts, and the platform's own `X-Fabrika-*`.
|
|
219
|
+
*/
|
|
220
|
+
const FORBIDDEN_HEADERS = [
|
|
221
|
+
'accept-charset',
|
|
222
|
+
'accept-encoding',
|
|
223
|
+
'access-control-request-headers',
|
|
224
|
+
'access-control-request-method',
|
|
225
|
+
'connection',
|
|
226
|
+
'content-length',
|
|
227
|
+
'cookie2',
|
|
228
|
+
'date',
|
|
229
|
+
'dnt',
|
|
230
|
+
'expect',
|
|
231
|
+
'forwarded',
|
|
232
|
+
'host',
|
|
233
|
+
'keep-alive',
|
|
234
|
+
'origin',
|
|
235
|
+
'referer',
|
|
236
|
+
'set-cookie',
|
|
237
|
+
'te',
|
|
238
|
+
'trailer',
|
|
239
|
+
'transfer-encoding',
|
|
240
|
+
'upgrade',
|
|
241
|
+
'via',
|
|
242
|
+
'x-http-method',
|
|
243
|
+
'x-http-method-override',
|
|
244
|
+
'x-method-override',
|
|
245
|
+
'x-real-ip',
|
|
246
|
+
]
|
|
247
|
+
const FORBIDDEN_HEADER_PREFIXES = ['proxy-', 'sec-', 'x-forwarded-', 'x-fabrika-']
|
|
248
|
+
|
|
249
|
+
function withoutForbiddenHeaders(headers: Headers): Headers {
|
|
250
|
+
for (const name of [...headers.keys()]) {
|
|
251
|
+
if (FORBIDDEN_HEADERS.includes(name) || FORBIDDEN_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))) {
|
|
252
|
+
headers.delete(name)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return headers
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The credentials a redirect to another origin drops. */
|
|
259
|
+
const CREDENTIAL_HEADERS = ['authorization', 'proxy-authorization', 'cookie']
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One request under public egress. Every hop is sent with `redirect: 'manual'`, so the runtime follows no redirect the
|
|
263
|
+
* guard has not checked: the caller's `follow` is done here, at most `maxRedirects` hops, each host checked again;
|
|
264
|
+
* `manual` hands the caller the redirect, whose next `fetch` passes the guard again; `error` refuses one. The request
|
|
265
|
+
* is bounded in time and its answer's body in size.
|
|
266
|
+
*/
|
|
267
|
+
async function publicFetch(
|
|
268
|
+
send: Fetch,
|
|
269
|
+
site: string,
|
|
270
|
+
rules: PublicEgressRules,
|
|
271
|
+
input: string | URL | Request,
|
|
272
|
+
init?: RequestInit,
|
|
273
|
+
): Promise<Response> {
|
|
274
|
+
const request = new Request(input, init)
|
|
275
|
+
// A timer the guard clears once the body is read, not `AbortSignal.timeout`: on workerd an uncleared timer keeps the
|
|
276
|
+
// Durable Object awake until it fires.
|
|
277
|
+
const deadline = new AbortController()
|
|
278
|
+
const timer = setTimeout(() => deadline.abort(new DOMException('public egress request timed out', 'TimeoutError')), PUBLIC_EGRESS_LIMITS.timeoutMs)
|
|
279
|
+
const done = (): void => clearTimeout(timer)
|
|
280
|
+
try {
|
|
281
|
+
return await follow(send, site, rules, request, AbortSignal.any([request.signal, deadline.signal]), done)
|
|
282
|
+
} catch (error) {
|
|
283
|
+
done()
|
|
284
|
+
throw error
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The hops of one request; `done` clears its deadline once its last answer's body is read. */
|
|
289
|
+
async function follow(
|
|
290
|
+
send: Fetch,
|
|
291
|
+
site: string,
|
|
292
|
+
rules: PublicEgressRules,
|
|
293
|
+
request: Request,
|
|
294
|
+
signal: AbortSignal,
|
|
295
|
+
done: () => void,
|
|
296
|
+
): Promise<Response> {
|
|
297
|
+
const mode = request.redirect
|
|
298
|
+
let url = new URL(request.url)
|
|
299
|
+
let method = request.method
|
|
300
|
+
const headers = withoutForbiddenHeaders(new Headers(request.headers))
|
|
301
|
+
// Read once, so that a 307 or a 308 sends it again.
|
|
302
|
+
let body: ArrayBuffer | null = request.body === null ? null : await request.arrayBuffer()
|
|
303
|
+
for (let hop = 0;; hop++) {
|
|
304
|
+
await admit(site, rules, url)
|
|
305
|
+
const response = await send(url.href, { method, headers: new Headers(headers), body, redirect: 'manual', signal })
|
|
306
|
+
const location = REDIRECTS.has(response.status) ? response.headers.get('location') : null
|
|
307
|
+
if (location === null || mode === 'manual') {
|
|
308
|
+
return await capped(site, response, url, hop > 0, done)
|
|
309
|
+
}
|
|
310
|
+
await response.body?.cancel()
|
|
311
|
+
if (mode === 'error') {
|
|
312
|
+
throw new TypeError(`${site}: ${url.origin} redirects, and the request's redirect mode is 'error'`)
|
|
313
|
+
}
|
|
314
|
+
if (hop >= PUBLIC_EGRESS_LIMITS.maxRedirects) {
|
|
315
|
+
throw new TypeError(`${site}: more than ${PUBLIC_EGRESS_LIMITS.maxRedirects} redirects, the last from ${url.origin}`)
|
|
316
|
+
}
|
|
317
|
+
const next = new URL(location, url)
|
|
318
|
+
const toGet = (response.status === 303 && method !== 'GET' && method !== 'HEAD')
|
|
319
|
+
|| ((response.status === 301 || response.status === 302) && method === 'POST')
|
|
320
|
+
if (toGet) {
|
|
321
|
+
method = 'GET'
|
|
322
|
+
body = null
|
|
323
|
+
for (const name of BODY_HEADERS) headers.delete(name)
|
|
324
|
+
}
|
|
325
|
+
if (next.origin !== url.origin) {
|
|
326
|
+
for (const name of CREDENTIAL_HEADERS) headers.delete(name)
|
|
327
|
+
}
|
|
328
|
+
url = next
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Refuses a hop public egress does not reach: by the URL, by the installation's hosts, by what its name resolves to. */
|
|
333
|
+
async function admit(site: string, rules: PublicEgressRules, url: URL): Promise<void> {
|
|
334
|
+
const refusal = publicEgressRefusal(url, rules.refusedHosts)
|
|
335
|
+
if (refusal !== null) {
|
|
336
|
+
throw new CapabilityRefusedError('refused_egress', `${site} calls ${url.origin}, which public egress refuses: ${refusal}`)
|
|
337
|
+
}
|
|
338
|
+
const resolve = rules.resolve
|
|
339
|
+
if (resolve === null) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const host = normalizedHost(url.hostname)
|
|
343
|
+
const addresses = await platformCall(() => resolve(host))
|
|
344
|
+
const internal = addresses.find((address) => !isPublicAddress(address))
|
|
345
|
+
if (addresses.length === 0 || internal !== undefined) {
|
|
346
|
+
throw new CapabilityRefusedError(
|
|
347
|
+
'refused_egress',
|
|
348
|
+
`${site} calls ${url.origin}, which public egress refuses: ${
|
|
349
|
+
internal === undefined ? `${host} resolves to no address` : `${host} resolves to ${internal}, which is not a public address`
|
|
350
|
+
}`,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `response` with its body bounded by `maxResponseBytes`, and the URL and redirect flag of the request it answers,
|
|
357
|
+
* which a response the guard builds would otherwise lose.
|
|
358
|
+
*/
|
|
359
|
+
async function capped(site: string, response: Response, url: URL, redirected: boolean, done: () => void): Promise<Response> {
|
|
360
|
+
const max = PUBLIC_EGRESS_LIMITS.maxResponseBytes
|
|
361
|
+
const tooLarge = () =>
|
|
362
|
+
new CapabilityRefusedError(
|
|
363
|
+
'oversized_egress',
|
|
364
|
+
`${site}: ${url.origin} answers more than ${max} bytes, the most public egress reads of one response`,
|
|
365
|
+
)
|
|
366
|
+
const declared = Number(response.headers.get('content-length') ?? '0')
|
|
367
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
368
|
+
await response.body?.cancel()
|
|
369
|
+
throw tooLarge()
|
|
370
|
+
}
|
|
371
|
+
if (response.body === null || response.status < 200 || response.status > 599) {
|
|
372
|
+
done()
|
|
373
|
+
return withRequestUrl(response, url, redirected)
|
|
374
|
+
}
|
|
375
|
+
const reader = response.body.getReader()
|
|
376
|
+
let seen = 0
|
|
377
|
+
const bound = new ReadableStream<Uint8Array>({
|
|
378
|
+
async pull(controller) {
|
|
379
|
+
const read = await reader.read().catch((error: unknown) => {
|
|
380
|
+
done()
|
|
381
|
+
throw error
|
|
382
|
+
})
|
|
383
|
+
if (read.done) {
|
|
384
|
+
done()
|
|
385
|
+
controller.close()
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
seen += read.value.byteLength
|
|
389
|
+
if (seen > max) {
|
|
390
|
+
done()
|
|
391
|
+
await reader.cancel()
|
|
392
|
+
controller.error(tooLarge())
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
controller.enqueue(read.value)
|
|
396
|
+
},
|
|
397
|
+
async cancel(reason) {
|
|
398
|
+
done()
|
|
399
|
+
await reader.cancel(reason)
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
const answer = new Response(bound, {
|
|
403
|
+
status: response.status,
|
|
404
|
+
statusText: response.statusText,
|
|
405
|
+
headers: response.headers,
|
|
406
|
+
})
|
|
407
|
+
return withRequestUrl(answer, url, redirected)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function withRequestUrl(response: Response, url: URL, redirected: boolean): Response {
|
|
411
|
+
try {
|
|
412
|
+
Object.defineProperties(response, { url: { value: url.href }, redirected: { value: redirected } })
|
|
413
|
+
} catch {
|
|
414
|
+
// A runtime whose responses refuse own properties keeps its own `url`; the body bound holds either way.
|
|
415
|
+
}
|
|
416
|
+
return response
|
|
417
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// fixed codes and bounded identifiers only, never an error's name or message: application code, drivers and even the
|
|
3
3
|
// framework's own refusals can put request data there. The event adds what was thrown as its cause chain, scrubbed of
|
|
4
4
|
// the environment's secrets and of credential patterns, a framework failure's message replaced by its catalog text
|
|
5
|
-
// (ADR-0059).
|
|
5
|
+
// (ADR-0059). A writing invocation that held its Durable Object's transaction too long is an event on the same budget.
|
|
6
6
|
|
|
7
7
|
import { CAPABILITY_ERRORS, CapabilityError, QUERY_REFUSALS, QueryRefusedError } from '@fabrika/db/framework'
|
|
8
8
|
import { isStructuralError } from '../errors'
|
|
@@ -127,4 +127,20 @@ export class FailureReports {
|
|
|
127
127
|
error: failure.error,
|
|
128
128
|
})
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A writing invocation that held its object's transaction past `@fabrika/db`'s report threshold, which already
|
|
133
|
+
* logged it. Never rejects; the delivery may outlive the response like a failure's.
|
|
134
|
+
*/
|
|
135
|
+
held(endpoint: string, heldMs: number): Promise<void> {
|
|
136
|
+
if (this.#reporter === null || !this.#budget.admit()) {
|
|
137
|
+
return Promise.resolve()
|
|
138
|
+
}
|
|
139
|
+
return this.#reporter.report({
|
|
140
|
+
fingerprint: ['fabrika-transaction-held', endpoint],
|
|
141
|
+
type: 'TransactionHeld',
|
|
142
|
+
message: `endpoint '${endpoint}' held its object's transaction for ${heldMs} ms`,
|
|
143
|
+
tags: { endpoint, code: 'transaction_held', held_ms: String(heldMs) },
|
|
144
|
+
})
|
|
145
|
+
}
|
|
130
146
|
}
|
package/src/implement/faults.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const SERVER_FAULTS = {
|
|
|
19
19
|
webhook_unverifiable: 'a webhook verifies a signature over its public URL (Twilio), and the environment has no FABRIKA_APP_ORIGIN',
|
|
20
20
|
webhook_claim_lost: "a delivery's claim expired and another copy of the delivery took it over before this one committed; nothing it wrote is kept",
|
|
21
21
|
workload_unauthorized: "the workload's key or token does not carry the authority its declaration needs",
|
|
22
|
+
terminal_refused: "IAM refused the application's own credential for an identification at a terminal",
|
|
22
23
|
request_cancelled: 'the request was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept',
|
|
23
24
|
file_object_missing: "a file's object is missing from storage",
|
|
24
25
|
file_object_mismatch: "a file's object has another size than its row records",
|
package/src/implement/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type {
|
|
|
53
53
|
} from './membership'
|
|
54
54
|
export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, PublicPrincipal, ServicePrincipal, UserPrincipal } from './principal'
|
|
55
55
|
export { type AppDeps, defineDeps, serve, type ServeOptions } from './serve'
|
|
56
|
+
export type { AssertedIdentity, OpenIdentification, TerminalIdentification } from './terminals'
|
|
56
57
|
export type {
|
|
57
58
|
AccessContext,
|
|
58
59
|
ConnectCall,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// commits it after the handler returned and its output passed validation, and rolls it back otherwise. Every handle
|
|
4
4
|
// is bound to the invocation: called from another request, another `boot`, or after it ended, it refuses.
|
|
5
5
|
|
|
6
|
-
import { type ContractArtifact, JOB_MAILERS, type JobMailer } from '@fabrika/auth-core'
|
|
6
|
+
import { type ContractArtifact, type DeclaredEgress, JOB_MAILERS, type JobMailer, PUBLIC_EGRESS } from '@fabrika/auth-core'
|
|
7
7
|
import {
|
|
8
8
|
type DataAccess,
|
|
9
9
|
type DataHandles,
|
|
@@ -34,8 +34,8 @@ export interface SiteDeclaration {
|
|
|
34
34
|
readonly binds: string | null
|
|
35
35
|
/** The token purposes `ctx.tokens.sign` may sign. */
|
|
36
36
|
readonly signs: readonly string[]
|
|
37
|
-
/** The HTTPS origins the global `fetch` reaches while the site's handler runs. */
|
|
38
|
-
readonly egress:
|
|
37
|
+
/** The HTTPS origins the global `fetch` reaches while the site's handler runs, or `'public'`: every public host (a cron job). */
|
|
38
|
+
readonly egress: DeclaredEgress
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** A public procedure's reach: the texts the verified signed tokens of its request carry, which name the rows it changes. */
|
|
@@ -166,10 +166,11 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
|
|
|
166
166
|
problems.push(`${where}: signs is not a list of token purposes`)
|
|
167
167
|
}
|
|
168
168
|
const egressField = ownField(declared, 'egress')
|
|
169
|
-
const
|
|
170
|
-
if (egressField !== undefined && (!Array.isArray(egressField) ||
|
|
171
|
-
problems.push(`${where}: egress is not a list of origins`)
|
|
169
|
+
const origins = Array.isArray(egressField) ? egressField.filter((origin): origin is string => typeof origin === 'string') : []
|
|
170
|
+
if (egressField !== undefined && egressField !== PUBLIC_EGRESS && (!Array.isArray(egressField) || origins.length !== egressField.length)) {
|
|
171
|
+
problems.push(`${where}: egress is not a list of origins or '${PUBLIC_EGRESS}'`)
|
|
172
172
|
}
|
|
173
|
+
const egress: DeclaredEgress = egressField === PUBLIC_EGRESS ? PUBLIC_EGRESS : Object.freeze([...new Set(origins)].sort())
|
|
173
174
|
return Object.freeze({
|
|
174
175
|
sql: sql === 'read' || sql === 'write' ? sql : null,
|
|
175
176
|
unscoped: Object.freeze(unscoped),
|
|
@@ -179,7 +180,7 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
|
|
|
179
180
|
kicks: Object.freeze([...new Set(kicks)].sort()),
|
|
180
181
|
binds: typeof binds === 'string' ? binds : null,
|
|
181
182
|
signs: Object.freeze([...new Set(signs)].sort()),
|
|
182
|
-
egress
|
|
183
|
+
egress,
|
|
183
184
|
})
|
|
184
185
|
}
|
|
185
186
|
|
|
@@ -224,7 +225,7 @@ export function approvedReach(site: string, artifact: ContractArtifact): SiteDec
|
|
|
224
225
|
kicks: Object.freeze([...(reach.kicks ?? [])]),
|
|
225
226
|
binds: reach.binds ?? null,
|
|
226
227
|
signs: Object.freeze([...(reach.signs ?? [])]),
|
|
227
|
-
egress: Object.freeze([...(reach.egress ?? [])]),
|
|
228
|
+
egress: reach.egress === PUBLIC_EGRESS ? PUBLIC_EGRESS : Object.freeze([...(reach.egress ?? [])]),
|
|
228
229
|
})
|
|
229
230
|
}
|
|
230
231
|
|
|
@@ -243,7 +244,7 @@ function describeDeclaration(declaration: SiteDeclaration): string {
|
|
|
243
244
|
...(declaration.kicks.length === 0 ? {} : { kicks: declaration.kicks }),
|
|
244
245
|
...(declaration.binds === null ? {} : { binds: declaration.binds }),
|
|
245
246
|
...(declaration.signs.length === 0 ? {} : { signs: declaration.signs }),
|
|
246
|
-
...(declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
|
|
247
|
+
...(declaration.egress !== PUBLIC_EGRESS && declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
|
|
247
248
|
})
|
|
248
249
|
}
|
|
249
250
|
|
|
@@ -321,6 +322,14 @@ export class Invocation {
|
|
|
321
322
|
return { check: boundTo(this.#frame, () => transaction.ended, handle), session: () => transaction.session() }
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Runs `work`, from the handler to `commit`, in this invocation's transaction. On SQLite it encloses `work`, and a
|
|
327
|
+
* hold past `@fabrika/db`'s report threshold reaches `held`, logged under `site`.
|
|
328
|
+
*/
|
|
329
|
+
enclose<T>(work: () => Promise<T>, site: string, held: (heldMs: number) => void): Promise<T> {
|
|
330
|
+
return this.#transaction.enclose(work, { site, held })
|
|
331
|
+
}
|
|
332
|
+
|
|
324
333
|
/** Commits; a request the caller abandoned meanwhile rolls back instead. */
|
|
325
334
|
async commit(signal: AbortSignal | null): Promise<void> {
|
|
326
335
|
if (signal?.aborted === true) {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// Who called: the caller kind a verified token presents, and the principal a handler sees for it.
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type AccessTokenClaims,
|
|
5
|
+
APP_CALLER_PREFIX,
|
|
6
|
+
type Caller,
|
|
7
|
+
callerOf,
|
|
8
|
+
matchAction,
|
|
9
|
+
OAUTH_CALLER,
|
|
10
|
+
type PermissionEntry,
|
|
11
|
+
type TerminalTokenClaims,
|
|
12
|
+
} from '@fabrika/auth-core'
|
|
4
13
|
import type { PageCaller } from '../client/caller'
|
|
5
14
|
|
|
6
15
|
/** A person: the IAM session, or a `px_` key bound to a user principal. */
|
|
@@ -11,6 +20,11 @@ export interface UserPrincipal {
|
|
|
11
20
|
readonly label: string
|
|
12
21
|
/** The mailbox IAM recorded for the person, lower-cased: the one to mail, not proof of possession. Only a person has one. */
|
|
13
22
|
readonly email: string
|
|
23
|
+
/**
|
|
24
|
+
* The enrolled terminal the person signed in on, when an application asserted them there (ADR-0067); absent for every
|
|
25
|
+
* other sign-in. Information for the handler: what the session may do is in its permissions already.
|
|
26
|
+
*/
|
|
27
|
+
readonly terminal?: TerminalTokenClaims
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
/** A machine: a `px_` key bound to a service principal. */
|
|
@@ -65,7 +79,16 @@ export function presentedBy(claims: AccessTokenClaims): Presented | Unpresented
|
|
|
65
79
|
switch (caller) {
|
|
66
80
|
case 'human':
|
|
67
81
|
if (claims.email === undefined) return 'no_mailbox'
|
|
68
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
caller,
|
|
84
|
+
principal: {
|
|
85
|
+
kind: 'user',
|
|
86
|
+
id: claims.sub,
|
|
87
|
+
label: claims.label ?? claims.sub,
|
|
88
|
+
email: claims.email,
|
|
89
|
+
...(claims.terminal === undefined ? {} : { terminal: Object.freeze({ id: claims.terminal.id, label: claims.terminal.label }) }),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
69
92
|
case 'machine':
|
|
70
93
|
return { caller, principal: { kind: 'service', id: claims.sub, label: claims.label ?? claims.sub } }
|
|
71
94
|
case 'link':
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `ctx.terminals`: the identification of a person at an enrolled terminal (ADR-0067). The application's identification
|
|
2
|
+
// page sends what the person entered to its own procedure, which asks IAM through these calls. The framework presents
|
|
3
|
+
// the request's credential (a public procedure's workload token), and IAM takes the asserting application from the
|
|
4
|
+
// verified transport. Throttling guesses is the application's: `inspect` hands it the terminal to key them on.
|
|
5
|
+
|
|
6
|
+
import type { IamTerminalRpc, IdentificationTerminal, MembershipCaller, TerminalFailure } from '@fabrika/auth-core'
|
|
7
|
+
import { ServerFault } from './faults'
|
|
8
|
+
|
|
9
|
+
/** An identification id longer than this is none IAM issued, so no call is made for it. */
|
|
10
|
+
const MAX_IDENTIFICATION_ID_LENGTH = 200
|
|
11
|
+
|
|
12
|
+
/** An open identification: the terminal it was opened on and when it expires (unix seconds). */
|
|
13
|
+
export interface OpenIdentification {
|
|
14
|
+
readonly terminal: IdentificationTerminal
|
|
15
|
+
readonly expiresAt: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* IAM's answer to an assertion: the URL the browser goes to next, or why IAM refused. `not_found` is an identification
|
|
20
|
+
* that is unknown, expired, used, cancelled or no longer open to this application; `invalid_principal` a principal
|
|
21
|
+
* that cannot sign in on a terminal (unknown, disabled, not a person, or external).
|
|
22
|
+
*/
|
|
23
|
+
export type AssertedIdentity =
|
|
24
|
+
| { readonly ok: true; readonly completionUrl: string }
|
|
25
|
+
| { readonly ok: false; readonly reason: 'not_found' | 'invalid_principal' }
|
|
26
|
+
|
|
27
|
+
/** `ctx.terminals` of an application that declares `terminal: { identify }`. */
|
|
28
|
+
export interface TerminalIdentification {
|
|
29
|
+
/** The open identification `id` (the page's `identification` query parameter), or null when there is none. */
|
|
30
|
+
inspect(id: string): Promise<OpenIdentification | null>
|
|
31
|
+
/**
|
|
32
|
+
* The application's word that `principalId` stands at the identification's terminal. IAM uses the identification up
|
|
33
|
+
* and, on `ok`, the handler sends the browser to `completionUrl`: IAM signs the person in there, and only on the
|
|
34
|
+
* terminal's own browser.
|
|
35
|
+
*/
|
|
36
|
+
assert(id: string, principalId: string): Promise<AssertedIdentity>
|
|
37
|
+
/** Ends an identification the person abandoned; one that is no longer open is already ended. */
|
|
38
|
+
cancel(id: string): Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `ctx.terminals` for one request: every call carries the request's credential and correlation id. */
|
|
42
|
+
export function terminalIdentification(rpc: IamTerminalRpc, caller: MembershipCaller): TerminalIdentification {
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
async inspect(id: string): Promise<OpenIdentification | null> {
|
|
45
|
+
if (!isIdentificationId(id)) return null
|
|
46
|
+
const result = await ask(() => rpc.inspectIdentification({ ...caller, id }), 'inspect an identification')
|
|
47
|
+
if (!result.ok) return null
|
|
48
|
+
return Object.freeze({ terminal: Object.freeze({ id: result.terminal.id, label: result.terminal.label }), expiresAt: result.expiresAt })
|
|
49
|
+
},
|
|
50
|
+
async assert(id: string, principalId: string): Promise<AssertedIdentity> {
|
|
51
|
+
if (!isIdentificationId(id)) return { ok: false, reason: 'not_found' }
|
|
52
|
+
const result = await ask(() => rpc.assertIdentity({ ...caller, id, principalId }), 'assert an identity')
|
|
53
|
+
return result.ok ? { ok: true, completionUrl: result.completionUrl } : { ok: false, reason: result.reason }
|
|
54
|
+
},
|
|
55
|
+
async cancel(id: string): Promise<void> {
|
|
56
|
+
if (isIdentificationId(id)) {
|
|
57
|
+
await ask(() => rpc.cancelIdentification({ ...caller, id }), 'cancel an identification')
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isIdentificationId(id: string): boolean {
|
|
64
|
+
return id !== '' && id.length <= MAX_IDENTIFICATION_ID_LENGTH
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runs one IAM call. A transport failure is a 503 that names nothing; IAM refusing the application's own credential is
|
|
69
|
+
* a server fault, because the visitor cannot change it. What remains is the handler's answer.
|
|
70
|
+
*/
|
|
71
|
+
async function ask<T extends { readonly ok: true }>(
|
|
72
|
+
work: () => Promise<T | TerminalFailure>,
|
|
73
|
+
operation: string,
|
|
74
|
+
): Promise<T | { readonly ok: false; readonly reason: 'not_found' | 'invalid_principal' }> {
|
|
75
|
+
let result: T | TerminalFailure
|
|
76
|
+
try {
|
|
77
|
+
result = await work()
|
|
78
|
+
} catch {
|
|
79
|
+
throw new ServerFault('iam_unavailable', `IAM could not be asked to ${operation}`, 503)
|
|
80
|
+
}
|
|
81
|
+
if (result.ok) {
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
switch (result.reason) {
|
|
85
|
+
case 'not_found':
|
|
86
|
+
case 'invalid_principal':
|
|
87
|
+
return { ok: false, reason: result.reason }
|
|
88
|
+
case 'missing_token':
|
|
89
|
+
case 'invalid_token':
|
|
90
|
+
case 'not_allowed':
|
|
91
|
+
throw new ServerFault('terminal_refused', `IAM refused the application's credential to ${operation}: ${result.reason}`)
|
|
92
|
+
}
|
|
93
|
+
}
|