@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.
Files changed (194) hide show
  1. package/package.json +40 -24
  2. package/skill/SKILL.md +27 -3
  3. package/skill/references/app-setup.md +8 -8
  4. package/skill/references/dev.md +2 -1
  5. package/skill/references/endpoint.md +7 -3
  6. package/skill/references/errors.md +39 -35
  7. package/skill/references/files.md +4 -2
  8. package/skill/references/table-and-migration.md +19 -2
  9. package/skill/references/tests.md +11 -0
  10. package/skill/references/webhooks-links-connectors.md +1 -1
  11. package/src/build-recipe.ts +198 -0
  12. package/src/build.ts +55 -4
  13. package/src/bun.ts +23 -8
  14. package/src/cloudflare/application.ts +352 -0
  15. package/src/cloudflare/bindings.ts +138 -0
  16. package/src/cloudflare/names.ts +10 -0
  17. package/src/cloudflare/ops.ts +246 -0
  18. package/src/cloudflare/r2-files.ts +172 -0
  19. package/src/cloudflare.ts +99 -146
  20. package/src/contract/app-id.ts +5 -0
  21. package/src/contract/artifact-schema.ts +2 -0
  22. package/src/contract/build.ts +35 -1
  23. package/src/contract/compile.ts +1 -0
  24. package/src/contract/context.ts +9 -0
  25. package/src/contract/declaration.ts +2 -0
  26. package/src/contract/define.ts +28 -2
  27. package/src/contract/kit.ts +4 -1
  28. package/src/contract/platform.ts +3 -2
  29. package/src/contract/vocabulary.ts +26 -8
  30. package/src/dev/changes.ts +35 -0
  31. package/src/dev/contract-digest.ts +17 -0
  32. package/src/dev/index.ts +11 -0
  33. package/src/dev/launcher.ts +713 -0
  34. package/src/dev/output.ts +72 -0
  35. package/src/dev/wire.ts +136 -0
  36. package/src/files/archive.ts +2 -2
  37. package/src/files/job-names.ts +12 -0
  38. package/src/files/kinds.ts +19 -7
  39. package/src/files/scan.ts +3 -4
  40. package/src/files/streams.ts +3 -1
  41. package/src/implement/anonymous.ts +11 -1
  42. package/src/implement/boot.ts +61 -4
  43. package/src/implement/bounded.ts +21 -2
  44. package/src/implement/capability.ts +15 -3
  45. package/src/implement/dispatch.ts +249 -145
  46. package/src/implement/failures.ts +17 -1
  47. package/src/implement/faults.ts +1 -0
  48. package/src/implement/index.ts +1 -0
  49. package/src/implement/invocation.ts +8 -0
  50. package/src/implement/principal.ts +25 -2
  51. package/src/implement/terminals.ts +93 -0
  52. package/src/implement/workload.ts +24 -13
  53. package/src/jobs/report.ts +5 -1
  54. package/src/jobs/runner.ts +33 -17
  55. package/src/jobs/schedule.ts +7 -1
  56. package/src/jobs/scrub.ts +14 -1
  57. package/src/testing/endpoint-access.ts +180 -137
  58. package/src/testing/kit.ts +20 -2
  59. package/src/testing/proxy.ts +5 -5
  60. package/types/src/agent-skill.d.ts +29 -0
  61. package/types/src/build-recipe.d.ts +70 -0
  62. package/types/src/build.d.ts +29 -0
  63. package/types/src/bun.d.ts +66 -0
  64. package/types/src/client/caller.d.ts +19 -0
  65. package/types/src/client/contract.d.ts +131 -0
  66. package/types/src/client/error-reporting.d.ts +66 -0
  67. package/types/src/client/index.d.ts +4 -0
  68. package/types/src/client/rpc.d.ts +14 -0
  69. package/types/src/client/share.d.ts +2 -0
  70. package/types/src/client/transport.d.ts +42 -0
  71. package/types/src/cloudflare/application.d.ts +42 -0
  72. package/types/src/cloudflare/bindings.d.ts +27 -0
  73. package/types/src/cloudflare/names.d.ts +6 -0
  74. package/types/src/cloudflare/ops.d.ts +46 -0
  75. package/types/src/cloudflare/r2-files.d.ts +9 -0
  76. package/types/src/cloudflare.d.ts +42 -0
  77. package/types/src/contract/app-id.d.ts +2 -0
  78. package/types/src/contract/artifact-schema.d.ts +8 -0
  79. package/types/src/contract/attachment.d.ts +41 -0
  80. package/types/src/contract/build.d.ts +12 -0
  81. package/types/src/contract/compile.d.ts +11 -0
  82. package/types/src/contract/connect.d.ts +51 -0
  83. package/types/src/contract/context.d.ts +510 -0
  84. package/types/src/contract/declaration.d.ts +307 -0
  85. package/types/src/contract/defaults.d.ts +208 -0
  86. package/types/src/contract/define.d.ts +376 -0
  87. package/types/src/contract/file.d.ts +44 -0
  88. package/types/src/contract/group.d.ts +274 -0
  89. package/types/src/contract/handled.d.ts +18 -0
  90. package/types/src/contract/index.d.ts +19 -0
  91. package/types/src/contract/kit.d.ts +211 -0
  92. package/types/src/contract/platform.d.ts +9 -0
  93. package/types/src/contract/problems.d.ts +14 -0
  94. package/types/src/contract/redirect.d.ts +25 -0
  95. package/types/src/contract/schema-from-shape.d.ts +10 -0
  96. package/types/src/contract/schema-shape.d.ts +5 -0
  97. package/types/src/contract/snapshot-source.d.ts +15 -0
  98. package/types/src/contract/token.d.ts +58 -0
  99. package/types/src/contract/vocabulary.d.ts +32 -0
  100. package/types/src/contract/webhook-binding.d.ts +24 -0
  101. package/types/src/contract/webhook-scheme.d.ts +23 -0
  102. package/types/src/dev/changes.d.ts +7 -0
  103. package/types/src/dev/contract-digest.d.ts +1 -0
  104. package/types/src/dev/index.d.ts +2 -0
  105. package/types/src/dev/launcher.d.ts +32 -0
  106. package/types/src/dev/output.d.ts +28 -0
  107. package/types/src/dev/wire.d.ts +126 -0
  108. package/types/src/disk-assets.d.ts +3 -0
  109. package/types/src/errors.d.ts +46 -0
  110. package/types/src/fetch.d.ts +7 -0
  111. package/types/src/files/archive.d.ts +26 -0
  112. package/types/src/files/clamscan.d.ts +32 -0
  113. package/types/src/files/handle.d.ts +106 -0
  114. package/types/src/files/job-names.d.ts +9 -0
  115. package/types/src/files/kinds.d.ts +101 -0
  116. package/types/src/files/scan.d.ts +78 -0
  117. package/types/src/files/sniff.d.ts +7 -0
  118. package/types/src/files/streams.d.ts +17 -0
  119. package/types/src/files/treatment.d.ts +18 -0
  120. package/types/src/from-row.d.ts +20 -0
  121. package/types/src/honeypot.d.ts +2 -0
  122. package/types/src/implement/anonymous.d.ts +28 -0
  123. package/types/src/implement/attachment.d.ts +14 -0
  124. package/types/src/implement/boot.d.ts +114 -0
  125. package/types/src/implement/bounded.d.ts +32 -0
  126. package/types/src/implement/browser-config.d.ts +19 -0
  127. package/types/src/implement/capability.d.ts +142 -0
  128. package/types/src/implement/challenge.d.ts +75 -0
  129. package/types/src/implement/client-address.d.ts +11 -0
  130. package/types/src/implement/connect.d.ts +67 -0
  131. package/types/src/implement/crashes.d.ts +31 -0
  132. package/types/src/implement/dispatch.d.ts +220 -0
  133. package/types/src/implement/egress.d.ts +14 -0
  134. package/types/src/implement/environment.d.ts +14 -0
  135. package/types/src/implement/failures.d.ts +39 -0
  136. package/types/src/implement/faults.d.ts +45 -0
  137. package/types/src/implement/headers.d.ts +7 -0
  138. package/types/src/implement/implement.d.ts +58 -0
  139. package/types/src/implement/index.d.ts +25 -0
  140. package/types/src/implement/invocation.d.ts +125 -0
  141. package/types/src/implement/membership.d.ts +201 -0
  142. package/types/src/implement/multipart.d.ts +36 -0
  143. package/types/src/implement/params.d.ts +7 -0
  144. package/types/src/implement/principal.d.ts +57 -0
  145. package/types/src/implement/project.d.ts +11 -0
  146. package/types/src/implement/public.d.ts +61 -0
  147. package/types/src/implement/serve.d.ts +25 -0
  148. package/types/src/implement/terminals.d.ts +33 -0
  149. package/types/src/implement/tokens.d.ts +49 -0
  150. package/types/src/implement/types.d.ts +243 -0
  151. package/types/src/implement/urls.d.ts +15 -0
  152. package/types/src/implement/verifiers.d.ts +19 -0
  153. package/types/src/implement/webhook.d.ts +55 -0
  154. package/types/src/implement/workload.d.ts +56 -0
  155. package/types/src/implement/xlsx.d.ts +6 -0
  156. package/types/src/index.d.ts +12 -0
  157. package/types/src/jobs/exception.d.ts +40 -0
  158. package/types/src/jobs/mail.d.ts +67 -0
  159. package/types/src/jobs/report.d.ts +56 -0
  160. package/types/src/jobs/runner.d.ts +151 -0
  161. package/types/src/jobs/schedule.d.ts +17 -0
  162. package/types/src/jobs/scrub.d.ts +12 -0
  163. package/types/src/jobs/types.d.ts +98 -0
  164. package/types/src/react/caller.d.ts +30 -0
  165. package/types/src/react/errors.d.ts +40 -0
  166. package/types/src/react/hooks.d.ts +110 -0
  167. package/types/src/react/index.d.ts +5 -0
  168. package/types/src/react/provider.d.ts +58 -0
  169. package/types/src/react/store.d.ts +68 -0
  170. package/types/src/react/testing.d.ts +28 -0
  171. package/types/src/react/types.d.ts +33 -0
  172. package/types/src/rpc/builder.d.ts +52 -0
  173. package/types/src/rpc/contract.d.ts +23 -0
  174. package/types/src/rpc/dispatcher.d.ts +11 -0
  175. package/types/src/rpc/types.d.ts +62 -0
  176. package/types/src/service/define.d.ts +42 -0
  177. package/types/src/service/index.d.ts +3 -0
  178. package/types/src/service/serve.d.ts +28 -0
  179. package/types/src/standard-schema.d.ts +55 -0
  180. package/types/src/testing/caller.d.ts +95 -0
  181. package/types/src/testing/challenge.d.ts +11 -0
  182. package/types/src/testing/dev.d.ts +97 -0
  183. package/types/src/testing/endpoint-access.d.ts +107 -0
  184. package/types/src/testing/index.d.ts +12 -0
  185. package/types/src/testing/isolation.d.ts +3 -0
  186. package/types/src/testing/kit.d.ts +187 -0
  187. package/types/src/testing/membership.d.ts +132 -0
  188. package/types/src/testing/policy-isolation.d.ts +113 -0
  189. package/types/src/testing/preload.d.ts +1 -0
  190. package/types/src/testing/proxy.d.ts +40 -0
  191. package/types/src/testing/runner.d.ts +8 -0
  192. package/types/src/testing/sample-file.d.ts +5 -0
  193. package/types/src/testing/status.d.ts +4 -0
  194. 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 { type AccessTokenClaims, APP_CALLER_PREFIX, type Caller, callerOf, matchAction, OAUTH_CALLER, type PermissionEntry } from '@fabrika/auth-core'
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 { caller, principal: { kind: 'user', id: claims.sub, label: claims.label ?? claims.sub, email: claims.email } }
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 response = await fetcher(
43
- new Request(new URL(WORKLOAD_TOKEN_PATH, issuer), {
44
- method: 'POST',
45
- headers: { authorization: `Bearer ${input.key}`, 'content-type': 'application/json' },
46
- body: JSON.stringify({ app: input.app }),
47
- redirect: 'manual',
48
- signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
49
- }),
50
- )
51
- const parsed = response.status === 200 ? mintResultSchema.safeParse(await response.json()) : null
52
- if (parsed === null || !parsed.success) {
53
- throw new Error(`IAM answered the workload token exchange with ${response.status}`)
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
  }
@@ -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: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
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
 
@@ -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 => Bun.randomUUIDv7()
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>(work: (tx: object) => Promise<R>): Promise<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
- return data.transaction(async (session) => {
905
- let open = true
906
- const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open, undefined, binding)
907
- try {
908
- const sql = site.sql === null ? undeclaredSql(job, 'job', 'tx.sql') : jobSql(frame, data, site.sql, session, () => !open)
909
- // Located in this transaction, so an object it wrote is found before the commit.
910
- const located = (action: string, dimension: string, id: string) => {
911
- boundTo(frame, () => !open, 'tx.ref')()
912
- return ref(action, dimension, id, session)
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
- const files = putAndOpen(boundTo(frame, () => !open, 'tx.files'))
915
- return await work(Object.freeze({ db: siteData(writes, (action) => writes.app(app(action))), sql, files, ref: located }))
916
- } finally {
917
- open = false
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
  }
@@ -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. The crontab, not this module, triggers a run.
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 { FABRIKA_CONTRACT_DIGEST, FABRIKA_IAM_APP_ID, FABRIKA_IAM_ISSUER, PLATFORM_RECEIVERS, platformTransportVariables } from '@fabrika/auth-core'
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