@fabrika/app 0.5.5 → 0.7.0

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 (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
package/src/jobs/mail.ts CHANGED
@@ -1,23 +1,29 @@
1
- // `ctx.mail` of a job that declares `mailer`, over the transport `boot` resolved: the installation's (`FABRIKA_EMAIL_*`),
2
- // the application's own (`serve(contract, { mail })`) or the test kit's stand-in. An installation without a transport
3
- // says so (`FABRIKA_EMAIL_PROVIDER=none`): its jobs get `ctx.mail` that is not available, and each send waits for one.
4
- // Each key carries the prefix of the installation and the application environment, so senders on one provider account
5
- // never share a key. Nothing here logs a message or a key.
1
+ // `ctx.mail` of a job that declares `mailer`, over the transport `boot` resolved: the installation's (`FABRIKA_EMAIL_*`,
2
+ // or the platform's relay under `FABRIKA_EMAIL_PROVIDER=platform`), the application's own (`serve(contract, { mail })`)
3
+ // or the test kit's stand-in. An installation without a transport says so (`FABRIKA_EMAIL_PROVIDER=none`): its jobs get
4
+ // `ctx.mail` that is not available, and each send waits for one. Each key carries the prefix of the installation and the
5
+ // application environment, so senders on one provider account never share a key. Nothing here logs a message or a key.
6
6
 
7
- import { type ContractArtifact, JOB_SITE_PREFIX, type JobMailer, sha256Hex } from '@fabrika/auth-core'
7
+ import { type ContractArtifact, JOB_SITE_PREFIX, type JobMailer, platformTransportVariables, sha256Hex } from '@fabrika/auth-core'
8
8
  import {
9
9
  EMAIL_PROVIDER_VARIABLE,
10
+ type EmailAttachment,
10
11
  EmailDeliveryError,
11
12
  type EmailDeliveryResult,
12
13
  type EmailMessage,
13
14
  type EmailProviderRegistry,
14
15
  type EmailSender,
15
16
  emailSenderFromEnv,
17
+ MAX_EMAIL_ATTACHMENTS,
16
18
  mayRetryDelivery,
17
19
  NO_EMAIL_PROVIDER,
18
20
  pickEmailEnvironment,
19
21
  } from '@fabrika/email'
22
+ import { PLATFORM_EMAIL_PROVIDER, PlatformEmailSender } from '@fabrika/email/platform'
20
23
  import { z } from 'zod/v4'
24
+ import { HttpError } from '../errors'
25
+ import type { OpenedFile } from '../files/handle'
26
+ import { platformCall } from '../implement/egress'
21
27
  import type { ApplicationEnvironment } from '../implement/implement'
22
28
  import type { AppMail, AppMailMessage } from './types'
23
29
 
@@ -72,10 +78,12 @@ export interface JobMailInput {
72
78
  readonly signal: AbortSignal
73
79
  /** Throws once the attempt has ended. */
74
80
  readonly assertLive: () => void
81
+ /** `ctx.files.open` of the attempt: the bytes of a live file whose reference it holds, at most `maxBytes`. */
82
+ readonly open: (file: unknown, options: { readonly maxBytes: number }) => Promise<OpenedFile>
75
83
  }
76
84
 
77
85
  export function jobMail(input: JobMailInput): AppMail {
78
- const { transport: { sender, available }, keyPrefix, signal: attempt, assertLive } = input
86
+ const { transport: { sender, available }, keyPrefix, signal: attempt, assertLive, open } = input
79
87
  return Object.freeze({
80
88
  available,
81
89
  capabilities: sender.capabilities,
@@ -85,6 +93,8 @@ export function jobMail(input: JobMailInput): AppMail {
85
93
  if (typeof key !== 'string' || !APP_MAIL_KEY.test(key)) {
86
94
  throw new EmailDeliveryError(`Email idempotency key is not 1..${MAX_APP_MAIL_KEY_LENGTH} visible ASCII characters`, { code: 'invalid_message' })
87
95
  }
96
+ // Without a transport the send fails `mail_not_configured` below, and no file is read for it.
97
+ const attachments = available ? await readAttachments(message.attachments ?? [], sender.capabilities.maxAttachmentBytes, open) : []
88
98
  const transported: EmailMessage = {
89
99
  to: message.to,
90
100
  subject: message.subject,
@@ -93,9 +103,12 @@ export function jobMail(input: JobMailInput): AppMail {
93
103
  idempotencyKey: `${keyPrefix}${key}`,
94
104
  ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
95
105
  ...(message.fromName === undefined ? {} : { fromName: message.fromName }),
106
+ ...(message.unsubscribe === undefined ? {} : { unsubscribeUrl: message.unsubscribe.url }),
107
+ ...(attachments.length === 0 ? {} : { attachments }),
96
108
  }
97
109
  try {
98
- return await sender.send(transported, signal === undefined ? attempt : AbortSignal.any([attempt, signal]))
110
+ // The transport's request is the platform's, which the job's egress does not cover.
111
+ return await platformCall(() => sender.send(transported, signal === undefined ? attempt : AbortSignal.any([attempt, signal])))
99
112
  } catch (error) {
100
113
  if (error instanceof EmailDeliveryError) {
101
114
  throw error
@@ -108,6 +121,52 @@ export function jobMail(input: JobMailInput): AppMail {
108
121
  })
109
122
  }
110
123
 
124
+ /**
125
+ * The bytes of each file of `files`, read through `ctx.files.open` (which refuses a file that is not live, or whose
126
+ * reference the attempt does not hold) and bounded together by the transport's limit.
127
+ */
128
+ async function readAttachments(
129
+ files: readonly unknown[],
130
+ maxBytes: number | undefined,
131
+ open: JobMailInput['open'],
132
+ ): Promise<readonly EmailAttachment[]> {
133
+ if (files.length === 0) {
134
+ return []
135
+ }
136
+ if (maxBytes === undefined) {
137
+ throw new EmailDeliveryError('This mail transport sends no attachments', { code: 'invalid_message' })
138
+ }
139
+ if (files.length > MAX_EMAIL_ATTACHMENTS) {
140
+ throw new EmailDeliveryError(`Email has more than ${MAX_EMAIL_ATTACHMENTS} attachments`, { code: 'invalid_message' })
141
+ }
142
+ const attachments: EmailAttachment[] = []
143
+ let remaining = maxBytes
144
+ for (const file of files) {
145
+ const opened = await open(file, { maxBytes: remaining }).catch((error: unknown) => {
146
+ if (error instanceof HttpError && error.type === 'too_large') {
147
+ throw new EmailDeliveryError(`Email attachments have more than the ${maxBytes} bytes this transport sends`, { code: 'invalid_message' })
148
+ }
149
+ throw error
150
+ })
151
+ remaining -= opened.size
152
+ attachments.push({ filename: attachmentName(opened), contentType: opened.type, content: opened.bytes })
153
+ }
154
+ return attachments
155
+ }
156
+
157
+ const MAX_ATTACHMENT_NAME_LENGTH = 255
158
+
159
+ /** The uploader's name with no path separator or quote, which every transport quotes; `attachment` without one. */
160
+ function attachmentName(file: OpenedFile): string {
161
+ let name = ''
162
+ for (const character of (file.name ?? '').replace(/[/\\"]/g, '_')) {
163
+ if (name.length + character.length > MAX_ATTACHMENT_NAME_LENGTH) break
164
+ name += character
165
+ }
166
+ const trimmed = name.trim()
167
+ return trimmed === '' ? 'attachment' : trimmed
168
+ }
169
+
111
170
  export interface MailTransportInput {
112
171
  readonly artifact: ContractArtifact
113
172
  /** The framework's variables, whose `FABRIKA_EMAIL_*` configure the installation's transport. */
@@ -166,7 +225,7 @@ function installationTransport(names: readonly string[], input: MailTransportInp
166
225
  const env = pickEmailEnvironment(input.framework)
167
226
  let sender: EmailSender | null
168
227
  try {
169
- sender = emailSenderFromEnv(env, input.registry)
228
+ sender = env[EMAIL_PROVIDER_VARIABLE] === PLATFORM_EMAIL_PROVIDER ? platformSender(input.framework) : emailSenderFromEnv(env, input.registry)
170
229
  } catch (error) {
171
230
  if (!(error instanceof EmailDeliveryError)) {
172
231
  throw error
@@ -190,6 +249,29 @@ function installationTransport(names: readonly string[], input: MailTransportInp
190
249
  return null
191
250
  }
192
251
 
252
+ /** The platform's relay, from its receiver's variables; a refusal names them and never their values. */
253
+ function platformSender(framework: Readonly<Record<string, string>>): EmailSender {
254
+ const variables = platformTransportVariables('mail')
255
+ const url = framework[variables.url] ?? ''
256
+ const key = framework[variables.key] ?? ''
257
+ const missing = [variables.url, variables.key].filter((name) => (framework[name] ?? '') === '')
258
+ if (missing.length > 0) {
259
+ throw new EmailDeliveryError(`Email configuration is invalid: ${missing.map((name) => `${name} is missing`).join('; ')}`, {
260
+ code: 'invalid_configuration',
261
+ })
262
+ }
263
+ try {
264
+ return new PlatformEmailSender({ url, key })
265
+ } catch (error) {
266
+ if (!(error instanceof EmailDeliveryError)) {
267
+ throw error
268
+ }
269
+ throw new EmailDeliveryError(`Email configuration is invalid: ${variables.url} or ${variables.key} is invalid (${error.message})`, {
270
+ code: 'invalid_configuration',
271
+ })
272
+ }
273
+ }
274
+
193
275
  function ownTransport(names: readonly string[], input: MailTransportInput, problems: string[]): JobMailTransport | null {
194
276
  if (input.own === undefined) {
195
277
  return null
@@ -221,7 +303,13 @@ function ownTransport(names: readonly string[], input: MailTransportInput, probl
221
303
  return null
222
304
  }
223
305
 
224
- const SenderCapabilities = z.object({ idempotent: z.boolean(), confirmation: z.enum(['sync', 'queued']), maxBatch: z.int().positive() })
306
+ const SenderCapabilities = z.object({
307
+ idempotent: z.boolean(),
308
+ confirmation: z.enum(['sync', 'queued']),
309
+ maxBatch: z.int().positive(),
310
+ listUnsubscribe: z.boolean().optional(),
311
+ maxAttachmentBytes: z.int().positive().optional(),
312
+ })
225
313
 
226
314
  /** What `serve`'s `mail` returned: a sender, `null` for none, `undefined` for anything else. */
227
315
  function parseSender(value: unknown): EmailSender | null | undefined {
@@ -1,7 +1,7 @@
1
1
  // The job runner (`fabrika job run <name>`): one run of one `c.cron` job. A run takes the job's lease or does not
2
2
  // start; each attempt holds one generation of it with a deadline the database enforces, so a write of an attempt
3
- // that timed out, failed or lost the lease never commits (`@fabrika/db`'s fence). A form's `then` kicks a run in the
4
- // background without waiting for any lock; a kick that finds the lease held leaves the shared pending flag, and the run
3
+ // that timed out, failed or lost the lease never commits (`@fabrika/db`'s fence). A site's `ctx.kick` sends a run in
4
+ // the background after its commit, without waiting for any lock; a kick that finds the lease held leaves the shared pending flag, and the run
5
5
  // holding it runs once more after it succeeded. Failures, crashed runs and a job that stopped succeeding are reported
6
6
  // to Operations, each under a fixed failure code.
7
7
 
@@ -11,8 +11,17 @@ 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
13
  import { NotFoundError } from '../errors'
14
- import type { FileCollections } from '../files/collections'
15
- import { type CapabilityMint, CapabilityRefusedError, type Ref, RequestFrame, runInRequest, type Within } from '../implement/capability'
14
+ import { FileWork, type StoredFileKinds, storedFilesHandle } from '../files/kinds'
15
+ import {
16
+ type AppCapability,
17
+ type CapabilityMint,
18
+ CapabilityRefusedError,
19
+ type Ref,
20
+ RequestFrame,
21
+ runInRequest,
22
+ type Within,
23
+ } from '../implement/capability'
24
+ import { runAsSite } from '../implement/egress'
16
25
  import { failureCode, type FailureReason, failureReason } from '../implement/failures'
17
26
  import { ServerFault } from '../implement/faults'
18
27
  import type { ContractDatabase } from '../implement/implement'
@@ -24,20 +33,20 @@ import {
24
33
  jobHandles,
25
34
  jobSql,
26
35
  jobSqlOutsideTransaction,
27
- noFileCollections,
36
+ kickRecorder,
28
37
  siteData,
29
38
  type SiteDeclaration,
30
39
  undeclaredSql,
31
40
  } from '../implement/invocation'
32
41
  import { jobLinkIssuing, type JobLinkMount } from '../implement/membership'
33
- import type { WorkloadCredential } from '../implement/workload'
42
+ import type { SignedTokens } from '../implement/tokens'
43
+ import { type AppUrl, appUrl } from '../implement/urls'
44
+ import { type WorkloadCredential, workloadSite } from '../implement/workload'
34
45
  import { jobMail, type MailTransports } from './mail'
35
46
  import type { OperationsReporter } from './report'
36
47
  import { type CronSchedule, parseCron, periodAt } from './schedule'
37
48
  import type { AppMail, JobContext, JobPrincipal } from './types'
38
49
 
39
- /** An attempt of a job without a `timeout` is cancelled after this long. */
40
- export const DEFAULT_JOB_TIMEOUT_MS = 15 * 60_000
41
50
  /**
42
51
  * The lease outlives an attempt's deadline by this much: the runner renews or finishes a timed-out attempt's lease,
43
52
  * and no other run takes it over meanwhile. The attempt's writes stop at its deadline regardless.
@@ -47,8 +56,6 @@ export const LEASE_GRACE_MS = 60_000
47
56
  export const JOB_CHECK_INTERVAL_MS = 60_000
48
57
  const BACKOFF_BASE_MS = 1_000
49
58
  const BACKOFF_MAX_MS = 5 * 60_000
50
- const DURATION = /^([1-9][0-9]*)([smh])$/
51
- const UNIT_MS: Readonly<Record<string, number>> = { s: 1_000, m: 60_000, h: 3_600_000 }
52
59
 
53
60
  /**
54
61
  * Why a run failed. Fixed codes only: an application error's name and message may carry a secret, so neither is
@@ -167,12 +174,17 @@ interface RunnerContext {
167
174
  readonly reporter: OperationsReporter
168
175
  readonly mint: CapabilityMint
169
176
  readonly data: DataAccess | null
170
- readonly files: FileCollections | null
177
+ readonly fileKinds: StoredFileKinds | null
171
178
  readonly membership: IamMembership | null
172
179
  readonly capabilities: CapabilityAuthority
173
180
  readonly containers: ReadonlyMap<string, readonly string[]>
174
181
  readonly revealHandlerErrors: HandlerErrorSink | null
175
182
  readonly mail: MailTransports
183
+ /** Kicks a contract job in the background: what a job's own `ctx.kick` sends once its run succeeded. */
184
+ readonly kick: (name: string) => Promise<void>
185
+ /** The environment's signed tokens: `ctx.tokens` of a job that declares `signs`. */
186
+ readonly tokens: SignedTokens | null
187
+ readonly url: AppUrl
176
188
  }
177
189
 
178
190
  /** Where `fabrika app dev` shows what a handler threw, on the developer's own machine; nothing else sets one. */
@@ -194,9 +206,9 @@ export interface JobBindingInput {
194
206
  readonly mint: CapabilityMint
195
207
  /** The mini-ORM of the bound `scoped(tables)` layer, behind a staged-kit job's `ctx.db`. */
196
208
  readonly data: DataAccess | null
197
- /** The contract's file collections, behind a staged-kit job's `ctx.files` and `tx.files`; `null` without any. */
198
- readonly files?: FileCollections | null
199
- /** The framework's own jobs this contract needs: the scan of its `scan: true` file collections. */
209
+ /** The contract's file kinds, behind a staged-kit job's `ctx.files.put` and `.open`; `null` without any. */
210
+ readonly fileKinds?: StoredFileKinds | null
211
+ /** The framework's own jobs this contract needs: the scan of its `scan: true` file kinds, and the file sweep. */
200
212
  readonly frameworkJobs?: readonly FrameworkJob[]
201
213
  /** IAM's membership operations, which a job that `issues` links asks; `null` when the adapter supplies none. */
202
214
  readonly membership: IamMembership | null
@@ -208,6 +220,10 @@ export interface JobBindingInput {
208
220
  readonly revealHandlerErrors?: HandlerErrorSink
209
221
  /** The transports of the jobs that declare `mailer`, which `boot` resolved. */
210
222
  readonly mail: MailTransports
223
+ /** The environment's signed tokens, which a job that declares `signs` signs with. */
224
+ readonly tokens?: SignedTokens
225
+ /** `ctx.url`: absolute links to the application's own procedures; without it every link is refused. */
226
+ readonly url?: AppUrl
211
227
  }
212
228
 
213
229
  interface BoundFrameworkJob {
@@ -221,7 +237,7 @@ export interface BoundJobs {
221
237
  /** The handler ids the jobs took (`jobs.<name>`), so `boot` finds no undeclared handler among them. */
222
238
  readonly declared: readonly string[]
223
239
  /**
224
- * Kicks the contract job `name`, as a form's `then` does after its row committed: a run under the job's own key
240
+ * Kicks the contract job `name`, as a site's `ctx.kick` does after its commit: a run under the job's own key
225
241
  * now, or the shared pending flag for the run holding the lease, which then runs once more after it succeeded. It
226
242
  * waits for no lock, settles when that work has ended and never rejects; the schedule stays the recovery path.
227
243
  */
@@ -237,15 +253,6 @@ function ownChild(node: unknown, key: string): unknown {
237
253
  return typeof node === 'object' && node !== null && Object.hasOwn(node, key) ? Reflect.get(node, key) : undefined
238
254
  }
239
255
 
240
- function durationMs(timeout: string | null): number | null {
241
- if (timeout === null) {
242
- return DEFAULT_JOB_TIMEOUT_MS
243
- }
244
- const match = DURATION.exec(timeout)
245
- const unit = match?.[2] === undefined ? undefined : UNIT_MS[match[2]]
246
- return match?.[1] === undefined || unit === undefined ? null : Number.parseInt(match[1], 10) * unit
247
- }
248
-
249
256
  /** Binds every declared job to its handler, or adds why it cannot run to `problems`. */
250
257
  export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs {
251
258
  const entries = Object.entries(input.artifact.jobs)
@@ -265,26 +272,22 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
265
272
  if (schedule === null) {
266
273
  problems.push(`${where} has the schedule '${artifact.schedule}', which is not a cron expression (allowed: '0 3 * * *', '*/15 * * * *')`)
267
274
  }
268
- const timeoutMs = durationMs(artifact.timeout)
269
- if (timeoutMs === null) {
270
- problems.push(`${where} has the timeout '${artifact.timeout}', which is not a duration (allowed: '30s', '10m', '1h')`)
271
- }
272
275
  const node = ownChild(tree, name)
273
276
  const handler = isSiteHandler(node) ? node.handler : node
274
277
  const site = isSiteHandler(node) ? approvedDeclaration(where, `${JOB_SITE_PREFIX}${name}`, node.declared, 'job', input.artifact, problems) : null
275
- const credential = input.credentials.get(`job:${name}`)
278
+ const credential = input.credentials.get(workloadSite('job', name))
276
279
  const links = jobLinks(where, `${JOB_SITE_PREFIX}${name}`, site, input, problems)
277
280
  const mailer = input.artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.mailer ?? null
278
281
  if (typeof handler !== 'function') {
279
282
  problems.push(`${where} has no handler; add it to handlers.jobs`)
280
283
  } else if (site !== null && input.data === null) {
281
284
  problems.push(`${where} reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
282
- } else if (schedule !== null && timeoutMs !== null && credential !== undefined) {
285
+ } else if (schedule !== null && credential !== undefined) {
283
286
  bound.set(name, {
284
287
  name,
285
288
  artifact,
286
289
  schedule,
287
- timeoutMs,
290
+ timeoutMs: artifact.timeoutMs,
288
291
  handler: (args) => Reflect.apply(handler, undefined, [args]),
289
292
  credential,
290
293
  site,
@@ -312,12 +315,16 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
312
315
  reporter: chosen ?? input.reporter,
313
316
  mint: input.mint,
314
317
  data: input.data,
315
- files: input.files ?? null,
318
+ fileKinds: input.fileKinds ?? null,
316
319
  membership: input.membership,
317
320
  capabilities: input.capabilities,
318
321
  containers: input.containers,
319
322
  revealHandlerErrors: input.revealHandlerErrors ?? null,
320
323
  mail: input.mail,
324
+ tokens: input.tokens ?? null,
325
+ url: input.url ?? appUrl(input.artifact, null),
326
+ // A job's kicks run only after the job is bound, so `kick` below is defined by then.
327
+ kick: (name: string) => kick(name),
321
328
  })
322
329
  const jobs: ContractJobs = Object.freeze({
323
330
  names,
@@ -534,7 +541,9 @@ async function runLeased(
534
541
  await pendingTaken(job, context)
535
542
  const attempts = job.artifact.retries + 1
536
543
  progress.attempts = 1
537
- let outcome = await attemptOnce(job, fence, progress.attempts, clock, context, stop)
544
+ // The jobs the succeeding attempt kicked; a failed attempt's kicks are dropped with its writes.
545
+ const kicked: string[] = []
546
+ let outcome = await attemptOnce(job, fence, progress.attempts, clock, context, kicked, stop)
538
547
  while (outcome !== null && outcome.code !== 'stopped' && !outcome.final && progress.attempts < attempts) {
539
548
  console.error(`fabrika: job '${job.name}' attempt ${progress.attempts} of ${attempts} failed: ${describeFailure(outcome)}`)
540
549
  const delay = backoffMs(progress.attempts, clock.random())
@@ -546,10 +555,12 @@ async function runLeased(
546
555
  fence = next
547
556
  await clock.wait(delay, stop ?? new AbortController().signal)
548
557
  progress.attempts++
549
- outcome = await attemptOnce(job, fence, progress.attempts, clock, context, stop)
558
+ outcome = await attemptOnce(job, fence, progress.attempts, clock, context, kicked, stop)
550
559
  }
551
560
  if (outcome === null) {
552
561
  if (await leases.finish(fence, 'succeeded')) {
562
+ // After the lease is released, so a job that kicks another never waits on its own lease; a kick never rejects.
563
+ await Promise.all(kicked.map((name) => context.kick(name)))
553
564
  return { status: 'succeeded', attempts: progress.attempts }
554
565
  }
555
566
  return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
@@ -656,8 +667,10 @@ async function attemptOnce(
656
667
  attempt: number,
657
668
  clock: JobClock,
658
669
  context: RunnerContext,
670
+ kicked: string[],
659
671
  stop?: AbortSignal,
660
672
  ): Promise<JobFailure | null> {
673
+ kicked.length = 0
661
674
  if (isStopped(stop)) {
662
675
  return failure('stopped')
663
676
  }
@@ -673,10 +686,17 @@ async function attemptOnce(
673
686
  const cancel = new AbortController()
674
687
  const timer = new AbortController()
675
688
  const signal = stop === undefined ? cancel.signal : AbortSignal.any([cancel.signal, stop])
676
- const ctx = jobContext(job, frame, attempt, signal, context, authority.perms)
689
+ const kicks = kickRecorder(`job '${job.name}'`, job.site?.kicks ?? [])
690
+ const ctx = { ...jobContext(job, frame, attempt, signal, context, authority.perms), kick: kicks.kick }
677
691
  try {
678
692
  const ran = runInRequest(frame, async () => {
679
- await job.handler({ ctx })
693
+ const site = job.site
694
+ if (site === null) {
695
+ await job.handler({ ctx })
696
+ } else {
697
+ await runAsSite(`job '${job.name}'`, site.egress, async () => await job.handler({ ctx }))
698
+ }
699
+ kicked.push(...kicks.kicked())
680
700
  }).then(() => null, (error: unknown) => {
681
701
  const failed = failureOf(error, job.mailer === null ? null : (context.mail[job.mailer]?.sender.capabilities ?? null))
682
702
  if (failed.code === 'handler_failed') context.revealHandlerErrors?.(`job '${job.name}' attempt ${attempt}`, error)
@@ -730,7 +750,7 @@ function jobContext(
730
750
  perms: readonly PermissionEntry[],
731
751
  ): JobContext<string, string> {
732
752
  const principal: JobPrincipal = Object.freeze({ kind: 'job', id: `job:${job.name}`, job: job.name })
733
- const check = (action: string, dimension: string): void => {
753
+ const checkAction = (action: string): void => {
734
754
  if (frame.ended) {
735
755
  throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it mints no capability`)
736
756
  }
@@ -743,6 +763,9 @@ function jobContext(
743
763
  if (!permits([...perms], action)) {
744
764
  throw new CapabilityRefusedError('undeclared_action', `the token IAM signed for the key of job '${job.name}' does not carry this action`)
745
765
  }
766
+ }
767
+ const check = (action: string, dimension: string): void => {
768
+ checkAction(action)
746
769
  if (!Object.hasOwn(context.artifact.dimensions, dimension)) {
747
770
  throw new CapabilityRefusedError('undeclared_dimension', `job '${job.name}' named a dimension the contract does not declare`)
748
771
  }
@@ -765,14 +788,34 @@ function jobContext(
765
788
  }
766
789
  return context.mint.ref(frame, dimension, action, principal.id, id, chain)
767
790
  }
791
+ const app = <const Act extends string>(action: Act): AppCapability<Act> => {
792
+ checkAction(action)
793
+ return context.mint.app(frame, action, principal.id)
794
+ }
768
795
  return Object.freeze({
769
796
  principal,
770
797
  runId: frame.id,
771
798
  attempt,
772
799
  signal,
773
- ...(job.site === null || context.data === null ? {} : kitJobContext(frame, context.data, context.files, job.name, job.site, principal.id, ref)),
800
+ url: context.url,
801
+ ...(job.site === null || context.data === null
802
+ ? {}
803
+ : kitJobContext(
804
+ frame,
805
+ context.data,
806
+ context.fileKinds,
807
+ job.name,
808
+ job.site,
809
+ principal.id,
810
+ ref,
811
+ app,
812
+ periodAt(job.schedule, new Date()) ?? undefined,
813
+ )),
774
814
  ...(job.links === null || context.membership === null ? {} : { links: jobLinksOf(job, job.links, context.membership, frame, context) }),
775
815
  ...(job.mailer === null ? {} : { mail: jobMailOf(job, job.mailer, frame, signal, context) }),
816
+ ...(job.site === null || job.site.signs.length === 0 || context.tokens === null
817
+ ? {}
818
+ : { tokens: context.tokens.context(`job '${job.name}'`, job.site.signs) }),
776
819
  within<const Act extends string, const Dim extends string>(action: Act, dimension: Dim): Within<Dim, Act> {
777
820
  check(action, dimension)
778
821
  return context.mint.within(frame, dimension, action, principal.id, 'all')
@@ -780,6 +823,7 @@ function jobContext(
780
823
  ref<const Act extends string, const Dim extends string>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>> {
781
824
  return ref(action, dimension, id)
782
825
  },
826
+ app,
783
827
  })
784
828
  }
785
829
 
@@ -819,16 +863,14 @@ function jobMailOf(job: BoundJob, mailer: JobMailer, frame: RequestFrame, signal
819
863
  if (transport === null) {
820
864
  throw new ServerFault('facility_unbound', `job '${job.name}' declares mailer: '${mailer}', and boot resolved no transport for it`)
821
865
  }
822
- return jobMail({
823
- transport,
824
- keyPrefix: context.mail.keyPrefix,
825
- signal,
826
- assertLive: () => {
827
- if (frame.ended) {
828
- throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it sends no mail`)
829
- }
830
- },
831
- })
866
+ const assertLive = (): void => {
867
+ if (frame.ended) {
868
+ throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it sends no mail`)
869
+ }
870
+ }
871
+ // The attempt's holdings belong to its frame, so this work opens what `ctx.files.open` of the attempt opens.
872
+ const files = storedFilesHandle(context.fileKinds, new FileWork(frame, frame.id), { where: `job '${job.name}'`, check: assertLive, writes: false })
873
+ return jobMail({ transport, keyPrefix: context.mail.keyPrefix, signal, assertLive, open: files.open })
832
874
  }
833
875
 
834
876
  /**
@@ -838,49 +880,39 @@ function jobMailOf(job: BoundJob, mailer: JobMailer, frame: RequestFrame, signal
838
880
  function kitJobContext(
839
881
  frame: RequestFrame,
840
882
  data: DataAccess,
841
- files: FileCollections | null,
883
+ kinds: StoredFileKinds | null,
842
884
  name: string,
843
885
  site: SiteDeclaration,
844
886
  principal: string,
845
887
  ref: (action: string, dimension: string, id: string, using: Session) => Promise<Ref<string, string>>,
888
+ app: (action: string) => AppCapability<string>,
889
+ tickMs: number | undefined,
846
890
  ): Readonly<Record<string, unknown>> {
847
- const reads = jobHandles(frame, data, site, principal, 'job', async () => data.direct(), () => false)
891
+ // One attempt's files: what it reads, puts and writes, inside its transactions and outside them, belongs to it.
892
+ const work = new FileWork(frame, frame.id)
893
+ const binding = kinds?.binding(work)
894
+ const reads = jobHandles(frame, data, site, principal, 'job', async () => data.direct(), () => false, tickMs, binding)
848
895
  const job = `job '${name}'`
896
+ const putAndOpen = (check: () => void) => storedFilesHandle(kinds, work, { where: job, check, writes: true })
849
897
  return {
850
- db: siteData(reads),
898
+ db: siteData(reads, (action) => reads.app(app(action))),
851
899
  sql: site.sql === null ? undeclaredSql(job, 'job') : jobSqlOutsideTransaction(job),
852
- files: files === null ? noFileCollections(job) : files.site({
853
- where: job,
854
- frame,
855
- check: boundTo(frame, () => false, 'ctx.files'),
856
- session: async () => data.direct(),
857
- readOnly: 'a job removes files inside ctx.transaction((tx) => …), as tx.files',
858
- attaches: false,
859
- }),
900
+ files: putAndOpen(boundTo(frame, () => false, 'ctx.files')),
860
901
  transaction: async <R>(work: (tx: object) => Promise<R>): Promise<R> => {
861
902
  // Outside its attempt there is no lease to fence the transaction with, so it never opens.
862
903
  boundTo(frame, () => false)()
863
904
  return data.transaction(async (session) => {
864
905
  let open = true
865
- const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open)
906
+ const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open, undefined, binding)
866
907
  try {
867
908
  const sql = site.sql === null ? undeclaredSql(job, 'job', 'tx.sql') : jobSql(frame, data, site.sql, session, () => !open)
868
- const removing = files === null
869
- ? noFileCollections(job)
870
- : files.site({
871
- where: job,
872
- frame,
873
- check: boundTo(frame, () => !open, 'tx.files'),
874
- session: async () => session,
875
- readOnly: null,
876
- attaches: true,
877
- })
878
909
  // Located in this transaction, so an object it wrote is found before the commit.
879
910
  const located = (action: string, dimension: string, id: string) => {
880
911
  boundTo(frame, () => !open, 'tx.ref')()
881
912
  return ref(action, dimension, id, session)
882
913
  }
883
- return await work(Object.freeze({ db: siteData(writes), sql, files: removing, ref: located }))
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 }))
884
916
  } finally {
885
917
  open = false
886
918
  }
package/src/jobs/scrub.ts CHANGED
@@ -1,7 +1,7 @@
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 } from '@fabrika/auth-core'
4
+ import { FABRIKA_CONTRACT_DIGEST, FABRIKA_IAM_APP_ID, FABRIKA_IAM_ISSUER, PLATFORM_RECEIVERS, platformTransportVariables } from '@fabrika/auth-core'
5
5
  import type { CapturedEnvironment } from '../implement/environment'
6
6
 
7
7
  export const REDACTED = '[redacted]'
@@ -16,9 +16,12 @@ const PUBLIC_FRAMEWORK_VARIABLES: ReadonlySet<string> = new Set([
16
16
  'FABRIKA_ENVIRONMENT',
17
17
  'FABRIKA_IAM_RPC_URL',
18
18
  'FABRIKA_APP_DATABASE_CONNECTIONS',
19
+ // `platform` is eight characters, so a secret scrubber would redact the word wherever a message names it.
20
+ 'FABRIKA_EMAIL_PROVIDER',
19
21
  FABRIKA_CONTRACT_DIGEST,
20
22
  FABRIKA_IAM_APP_ID,
21
23
  FABRIKA_IAM_ISSUER,
24
+ ...PLATFORM_RECEIVERS.map((receiver) => platformTransportVariables(receiver).url),
22
25
  ])
23
26
 
24
27
  /**
package/src/jobs/types.ts CHANGED
@@ -3,7 +3,9 @@
3
3
 
4
4
  import type { EmailDeliveryResult, EmailSenderCapabilities } from '@fabrika/email'
5
5
  import type { CronJob, DimensionOf } from '../contract/define'
6
- import type { Ref, Within } from '../implement/capability'
6
+ import type { FileRef } from '../contract/file'
7
+ import type { AppCapability, Ref, Within } from '../implement/capability'
8
+ import type { AppUrl } from '../implement/urls'
7
9
 
8
10
  /** Who a job's writes are stamped with (`createdBy`): the job itself, never a person. */
9
11
  export interface JobPrincipal {
@@ -31,6 +33,10 @@ export interface JobContext<A extends string, D extends string> {
31
33
  within<const Act extends A, const Dim extends D>(action: Act, dimension: Dim): Within<Dim, Act>
32
34
  /** One object of `dimension` for a declared action, located now: what a write takes. A missing object is NotFound. */
33
35
  ref<const Act extends A, const Dim extends D>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>>
36
+ /** A declared action, application-wide: what `ctx.db.app('<action>')` reads and writes the application tables with. */
37
+ app<const Act extends A>(action: Act): AppCapability<Act>
38
+ /** An absolute link to a procedure of this application: `ctx.url('api.mail.unsubscribe', { query: { t } })`. */
39
+ readonly url: AppUrl
34
40
  }
35
41
 
36
42
  /** One message a job sends. The framework prefixes `idempotencyKey` before the transport sees it. */
@@ -43,6 +49,17 @@ export interface AppMailMessage {
43
49
  readonly idempotencyKey: string
44
50
  readonly replyTo?: string
45
51
  readonly fromName?: string
52
+ /**
53
+ * A one-click unsubscribe (RFC 8058): the transport writes `List-Unsubscribe` and `List-Unsubscribe-Post`. `url` is
54
+ * an `https:` link, usually `ctx.url` of a public procedure that verifies a signed token.
55
+ */
56
+ readonly unsubscribe?: { readonly url: string }
57
+ /**
58
+ * Files the message carries: references this attempt holds and may read, as `ctx.files.open` takes them. Only a
59
+ * `live` file is sent; the bytes of all of them together stay within the transport's `maxAttachmentBytes`, and a
60
+ * transport without it refuses the message. The idempotency key covers the attachments.
61
+ */
62
+ readonly attachments?: readonly FileRef[]
46
63
  }
47
64
 
48
65
  /** `ctx.mail` of a job that declares `mailer`. */
@@ -71,6 +88,6 @@ type JobsOf<C> = C extends { readonly surface: { readonly jobs: infer J } } ? J
71
88
  /** `{ jobs: { <name>: handler } }` for a contract that declares jobs, and nothing for one that declares none. */
72
89
  export type JobHandlersOf<C> = [JobsOf<C>] extends [never] ? unknown : {
73
90
  readonly jobs: {
74
- readonly [K in keyof JobsOf<C>]: JobsOf<C>[K] extends CronJob<infer A, string> ? JobHandler<A, DimensionOf<C>> : never
91
+ readonly [K in keyof JobsOf<C>]: JobsOf<C>[K] extends CronJob<infer A> ? JobHandler<A, DimensionOf<C>> : never
75
92
  }
76
93
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
6
6
  import { z } from 'zod/v4'
7
- import type { AttachmentProcedure, DownloadInput } from '../client/contract'
7
+ import type { AttachmentProcedure, DownloadedFile, DownloadInput, FileProcedure } from '../client/contract'
8
8
  import type { Schema } from '../contract/define'
9
9
  import { type FabrikaError, toFabrikaError } from './errors'
10
10
  import { parentOf, prefixOf, useFabrika } from './provider'
@@ -170,7 +170,7 @@ export type DownloadArgument<I> = DownloadInput<I>[0]
170
170
  * that hands the provider a client over the kit downloads over the kit. `mutate(input)` saves the file in a browser and
171
171
  * answers it as `data`; a refusal is the state's `error`, as for `useMutation`. It refetches nothing.
172
172
  */
173
- export function useDownload<I>(procedure: AttachmentProcedure<I>): MutationState<DownloadArgument<I>, File> {
173
+ export function useDownload<I>(procedure: AttachmentProcedure<I> | FileProcedure<I>): MutationState<DownloadArgument<I>, DownloadedFile> {
174
174
  const fabrika = useFabrika('useDownload')
175
175
  const run = useMemo(() => fabrika.attachment(procedure, 'useDownload'), [fabrika, procedure])
176
176
  return useRun(run, NOTHING_AFTER)