@fabrika/app 0.8.2 → 0.9.0-cloudflare.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -24
- package/skill/SKILL.md +27 -3
- package/skill/references/app-setup.md +8 -8
- package/skill/references/dev.md +2 -1
- package/skill/references/endpoint.md +7 -3
- package/skill/references/errors.md +39 -35
- package/skill/references/files.md +4 -2
- package/skill/references/table-and-migration.md +19 -2
- package/skill/references/tests.md +11 -0
- package/skill/references/webhooks-links-connectors.md +1 -1
- package/src/build-recipe.ts +198 -0
- package/src/build.ts +55 -4
- package/src/bun.ts +23 -8
- package/src/cloudflare/application.ts +352 -0
- package/src/cloudflare/bindings.ts +138 -0
- package/src/cloudflare/names.ts +10 -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 +2 -0
- package/src/contract/build.ts +35 -1
- 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/kit.ts +4 -1
- 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/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 +61 -4
- package/src/implement/bounded.ts +21 -2
- package/src/implement/capability.ts +15 -3
- package/src/implement/dispatch.ts +249 -145
- 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 +8 -0
- 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 +33 -17
- package/src/jobs/schedule.ts +7 -1
- package/src/jobs/scrub.ts +14 -1
- package/src/testing/endpoint-access.ts +180 -137
- 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 +27 -0
- package/types/src/cloudflare/names.d.ts +6 -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 +274 -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 +3 -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 +114 -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 +142 -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 +220 -0
- package/types/src/implement/egress.d.ts +14 -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 +151 -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/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 +107 -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
|
@@ -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
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@fabrika/auth-core'
|
|
17
17
|
import type { TokenVerifier } from '@fabrika/jwks'
|
|
18
18
|
import { z } from 'zod/v4'
|
|
19
|
+
import { deadline } from './bounded'
|
|
19
20
|
|
|
20
21
|
export type WorkloadKindOf = OwnWorkloadKind
|
|
21
22
|
|
|
@@ -39,20 +40,30 @@ export function issuerWorkloadTokens(
|
|
|
39
40
|
): WorkloadTokenSource {
|
|
40
41
|
return {
|
|
41
42
|
async exchange(input) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
const timer = deadline(EXCHANGE_TIMEOUT_MS)
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetcher(
|
|
46
|
+
new Request(new URL(WORKLOAD_TOKEN_PATH, issuer), {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { authorization: `Bearer ${input.key}`, 'content-type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ app: input.app }),
|
|
50
|
+
redirect: 'manual',
|
|
51
|
+
signal: timer.signal,
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
if (response.status !== 200) {
|
|
55
|
+
// Nothing reads a refusal's body, and once the deadline is cleared nothing else would end its stream.
|
|
56
|
+
await response.body?.cancel().catch(() => {})
|
|
57
|
+
throw new Error(`IAM answered the workload token exchange with ${response.status}`)
|
|
58
|
+
}
|
|
59
|
+
const parsed = mintResultSchema.safeParse(await response.json())
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
throw new Error(`IAM answered the workload token exchange with ${response.status}`)
|
|
62
|
+
}
|
|
63
|
+
return parsed.data
|
|
64
|
+
} finally {
|
|
65
|
+
timer.clear()
|
|
54
66
|
}
|
|
55
|
-
return parsed.data
|
|
56
67
|
},
|
|
57
68
|
}
|
|
58
69
|
}
|
package/src/jobs/report.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// groups a framework report by its fingerprint and raises its new-issue and regression alerts, so each kind of
|
|
4
4
|
// failure becomes one issue. What was thrown travels scrubbed as the report's cause chain (ADR-0059).
|
|
5
5
|
|
|
6
|
+
import { deadline } from '../implement/bounded'
|
|
6
7
|
import { exceptionChain, type ReportedException } from './exception'
|
|
7
8
|
import type { Scrubber } from './scrub'
|
|
8
9
|
|
|
@@ -136,6 +137,7 @@ export function crashEnvelope(kind: CrashKind, error: unknown, options: Envelope
|
|
|
136
137
|
|
|
137
138
|
async function deliver(options: OperationsReporterOptions, body: string): Promise<void> {
|
|
138
139
|
const send = options.fetch ?? ((request: Request) => fetch(request))
|
|
140
|
+
const timer = deadline(DELIVERY_TIMEOUT_MS)
|
|
139
141
|
const request = new Request(options.target.envelopeUrl, {
|
|
140
142
|
method: 'POST',
|
|
141
143
|
headers: {
|
|
@@ -143,7 +145,7 @@ async function deliver(options: OperationsReporterOptions, body: string): Promis
|
|
|
143
145
|
'x-sentry-auth': `Sentry sentry_version=7, sentry_key=${options.target.publicKey}`,
|
|
144
146
|
},
|
|
145
147
|
body,
|
|
146
|
-
signal:
|
|
148
|
+
signal: timer.signal,
|
|
147
149
|
})
|
|
148
150
|
try {
|
|
149
151
|
const response = await send(request)
|
|
@@ -154,6 +156,8 @@ async function deliver(options: OperationsReporterOptions, body: string): Promis
|
|
|
154
156
|
} catch {
|
|
155
157
|
// The error may quote the ingest URL with its key.
|
|
156
158
|
console.error('fabrika: a report to Operations could not be delivered')
|
|
159
|
+
} finally {
|
|
160
|
+
timer.clear()
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
|
package/src/jobs/runner.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type ContractArtifact, JOB_SITE_PREFIX, type JobArtifact, type JobMaile
|
|
|
10
10
|
import type { CapabilityAuthority } from '@fabrika/db/capability'
|
|
11
11
|
import { type DataAccess, type FrameworkTables, type JobLeases, type LeaseFence, LeaseLostError, type Session } from '@fabrika/db/framework'
|
|
12
12
|
import { EmailDeliveryError, type EmailSenderCapabilities, mayRetryDelivery } from '@fabrika/email'
|
|
13
|
+
import { uuidv7 } from '@fabrika/platform'
|
|
13
14
|
import { NotFoundError } from '../errors'
|
|
14
15
|
import { FileWork, type StoredFileKinds, storedFilesHandle } from '../files/kinds'
|
|
15
16
|
import {
|
|
@@ -117,6 +118,8 @@ export interface FrameworkJob {
|
|
|
117
118
|
export interface ContractJobs {
|
|
118
119
|
/** The contract's `c.cron` jobs; `run` also takes the name of a framework job the contract needs. */
|
|
119
120
|
readonly names: readonly string[]
|
|
121
|
+
/** Every job bound to run, the framework's included, with its schedule: what a minute tick runs when due. */
|
|
122
|
+
readonly scheduled: readonly { readonly name: string; readonly schedule: CronSchedule }[]
|
|
120
123
|
run(name: string, options?: JobRunOptions): Promise<JobRunResult>
|
|
121
124
|
/**
|
|
122
125
|
* Registers every job (its first-seen time), reports a run that crashed and a job that has not succeeded within
|
|
@@ -328,6 +331,7 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
|
|
|
328
331
|
})
|
|
329
332
|
const jobs: ContractJobs = Object.freeze({
|
|
330
333
|
names,
|
|
334
|
+
scheduled: Object.freeze([...bound.values(), ...frameworkJobs.values()].map(({ name, schedule }) => ({ name, schedule }))),
|
|
331
335
|
run(name: string, options: JobRunOptions = {}): Promise<JobRunResult> {
|
|
332
336
|
const own = frameworkJobs.get(name)
|
|
333
337
|
if (own !== undefined && framework !== null) {
|
|
@@ -432,7 +436,7 @@ function jobLinks(where: string, site: string, declaration: SiteDeclaration | nu
|
|
|
432
436
|
return { name, prefix: mount.prefix, dimension: mount.dimension, issuedBy: mount.issuedBy }
|
|
433
437
|
}
|
|
434
438
|
|
|
435
|
-
const attemptId = (): string =>
|
|
439
|
+
const attemptId = (): string => uuidv7()
|
|
436
440
|
|
|
437
441
|
/** Exponential from one second, capped at five minutes, with half of it jittered. */
|
|
438
442
|
function backoffMs(attempt: number, random: number): number {
|
|
@@ -898,25 +902,37 @@ function kitJobContext(
|
|
|
898
902
|
db: siteData(reads, (action) => reads.app(app(action))),
|
|
899
903
|
sql: site.sql === null ? undeclaredSql(job, 'job') : jobSqlOutsideTransaction(job),
|
|
900
904
|
files: putAndOpen(boundTo(frame, () => false, 'ctx.files')),
|
|
901
|
-
transaction: async <R>(
|
|
905
|
+
transaction: async <R>(body: (tx: object) => Promise<R>): Promise<R> => {
|
|
902
906
|
// Outside its attempt there is no lease to fence the transaction with, so it never opens.
|
|
903
907
|
boundTo(frame, () => false)()
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
908
|
+
// Its own files: a rollback releases only what this transaction staged, never a file of a concurrent one.
|
|
909
|
+
const own = new FileWork(frame, frame.id)
|
|
910
|
+
let rolledBack = false
|
|
911
|
+
try {
|
|
912
|
+
return await data.transaction(async (session) => {
|
|
913
|
+
let open = true
|
|
914
|
+
const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open, undefined, binding)
|
|
915
|
+
try {
|
|
916
|
+
const sql = site.sql === null ? undeclaredSql(job, 'job', 'tx.sql') : jobSql(frame, data, site.sql, session, () => !open)
|
|
917
|
+
// Located in this transaction, so an object it wrote is found before the commit.
|
|
918
|
+
const located = (action: string, dimension: string, id: string) => {
|
|
919
|
+
boundTo(frame, () => !open, 'tx.ref')()
|
|
920
|
+
return ref(action, dimension, id, session)
|
|
921
|
+
}
|
|
922
|
+
const files = storedFilesHandle(kinds, own, { where: job, check: boundTo(frame, () => !open, 'tx.files'), writes: true })
|
|
923
|
+
return await body(Object.freeze({ db: siteData(writes, (action) => writes.app(app(action))), sql, files, ref: located }))
|
|
924
|
+
} catch (error) {
|
|
925
|
+
rolledBack = true
|
|
926
|
+
throw error
|
|
927
|
+
} finally {
|
|
928
|
+
open = false
|
|
913
929
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
}
|
|
930
|
+
})
|
|
931
|
+
} catch (error) {
|
|
932
|
+
// A transaction the work threw out of is a known rollback; a failed commit is not, and the sweep decides.
|
|
933
|
+
if (rolledBack) await kinds?.release(own)
|
|
934
|
+
throw error
|
|
935
|
+
}
|
|
920
936
|
},
|
|
921
937
|
}
|
|
922
938
|
}
|
package/src/jobs/schedule.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The five-field cron expressions of `c.cron`, read in UTC: which minutes a job is due, so the runner can tell how
|
|
2
|
-
// long ago it should last have succeeded.
|
|
2
|
+
// long ago it should last have succeeded. On Bun the crontab triggers a run; on Cloudflare the application's object
|
|
3
|
+
// runs, at each minute tick, the jobs `isDueAt` that minute.
|
|
3
4
|
|
|
4
5
|
interface Field {
|
|
5
6
|
readonly min: number
|
|
@@ -96,6 +97,11 @@ function dayMatches(schedule: CronSchedule, day: Date): boolean {
|
|
|
96
97
|
return schedule.eitherDay ? byMonth || byWeek : byMonth && byWeek
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Whether the schedule is due in the UTC minute that holds `at`: what a Worker's minute tick runs (ADR-0065). */
|
|
101
|
+
export function isDueAt(schedule: CronSchedule, at: Date): boolean {
|
|
102
|
+
return schedule.minutes.has(at.getUTCMinutes()) && schedule.hours.has(at.getUTCHours()) && dayMatches(schedule, at)
|
|
103
|
+
}
|
|
104
|
+
|
|
99
105
|
const descending = (values: ReadonlySet<number>): number[] => [...values].sort((left, right) => right - left)
|
|
100
106
|
|
|
101
107
|
/** The latest `count` due minutes at or before `at`, newest first; fewer when the schedule is that rare. */
|
package/src/jobs/scrub.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// What an error report must not carry (ADR-0059): the values of the application's secrets, and anything shaped like a
|
|
2
2
|
// credential — a JWT, an `Authorization` scheme or header, a URL's userinfo, a sensitive query parameter.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
FABRIKA_APPLICATION_OBJECT,
|
|
6
|
+
FABRIKA_APPLICATION_OBJECTS,
|
|
7
|
+
FABRIKA_CONTRACT_DIGEST,
|
|
8
|
+
FABRIKA_IAM_APP_ID,
|
|
9
|
+
FABRIKA_IAM_ISSUER,
|
|
10
|
+
FABRIKA_OPS_KEY_DIGESTS,
|
|
11
|
+
PLATFORM_RECEIVERS,
|
|
12
|
+
platformTransportVariables,
|
|
13
|
+
} from '@fabrika/auth-core'
|
|
5
14
|
import type { CapturedEnvironment } from '../implement/environment'
|
|
6
15
|
|
|
7
16
|
export const REDACTED = '[redacted]'
|
|
@@ -21,6 +30,10 @@ const PUBLIC_FRAMEWORK_VARIABLES: ReadonlySet<string> = new Set([
|
|
|
21
30
|
FABRIKA_CONTRACT_DIGEST,
|
|
22
31
|
FABRIKA_IAM_APP_ID,
|
|
23
32
|
FABRIKA_IAM_ISSUER,
|
|
33
|
+
// Digests of the ops keys, which are no credential; the object names are words (`application`) a message carries.
|
|
34
|
+
FABRIKA_OPS_KEY_DIGESTS,
|
|
35
|
+
FABRIKA_APPLICATION_OBJECT,
|
|
36
|
+
FABRIKA_APPLICATION_OBJECTS,
|
|
24
37
|
...PLATFORM_RECEIVERS.map((receiver) => platformTransportVariables(receiver).url),
|
|
25
38
|
])
|
|
26
39
|
|