@fabrika/app 0.4.8 → 0.5.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.
@@ -1,6 +1,15 @@
1
1
  // @fabrika/app/implement — serving a contract: `serve` binds the handlers its endpoints carry, `boot` (called only by
2
2
  // the generated entrypoint, through a runtime adapter) serves it. Imports `node:async_hooks`, so it is not browser-safe.
3
3
  // `implement` (a mirrored handler tree) is the framework's own test entry, `./implement`, and no application's.
4
+ export {
5
+ EmailDeliveryError,
6
+ type EmailDeliveryErrorCode,
7
+ type EmailDeliveryOutcome,
8
+ type EmailDeliveryResult,
9
+ type EmailSender,
10
+ type EmailSenderCapabilities,
11
+ mayRetryDelivery,
12
+ } from '@fabrika/email'
4
13
  export { FILE_SWEEP_JOB } from '../files/collections'
5
14
  export type { FileCollectionHandle, FileCollectionReader, FileListing, ListedFile, OpenedFile } from '../files/handle'
6
15
  export {
@@ -15,7 +24,7 @@ export {
15
24
  export { crashEnvelope, type OperationsReport, type OperationsReporter, reportEnvelope } from '../jobs/report'
16
25
  export { type ContractJobs, type JobClock, JobError, type JobFailureCode, type JobRunOptions, type JobRunResult } from '../jobs/runner'
17
26
  export { secretScrubber } from '../jobs/scrub'
18
- export type { JobContext, JobHandler, JobHandlersOf, JobPrincipal } from '../jobs/types'
27
+ export type { AppMail, AppMailMessage, JobContext, JobHandler, JobHandlersOf, JobPrincipal } from '../jobs/types'
19
28
  export { LINK_BOOTSTRAP_SCRIPT } from './anonymous'
20
29
  export { boot, BootError, type BootOptions, type ContractApp, type IamKeySource } from './boot'
21
30
  export { readBoundedBytes, REQUEST_BUDGET_MS } from './bounded'
@@ -3,7 +3,7 @@
3
3
  // commits it after the handler returned and its output passed validation, and rolls it back otherwise. Every handle
4
4
  // is bound to the invocation: called from another request, another `boot`, or after it ended, it refuses.
5
5
 
6
- import type { ContractArtifact } from '@fabrika/auth-core'
6
+ import { type ContractArtifact, JOB_MAILERS, type JobMailer } from '@fabrika/auth-core'
7
7
  import {
8
8
  type DataAccess,
9
9
  type DataHandles,
@@ -16,6 +16,7 @@ import {
16
16
  } from '@fabrika/db/framework'
17
17
  import type { DataKind, UnscopedMode } from '@fabrika/db/tables'
18
18
  import type { SqlMode } from '../contract/context'
19
+ import { DECLARED_FIELDS } from '../contract/declaration'
19
20
  import type { FileSite } from '../files/collections'
20
21
  import { CapabilityRefusedError, isActiveRequest, type RequestFrame } from './capability'
21
22
  import { ServerFault } from './faults'
@@ -26,6 +27,8 @@ export interface SiteDeclaration {
26
27
  readonly unscoped: Readonly<Record<string, UnscopedMode>>
27
28
  readonly connect: readonly string[]
28
29
  readonly issues: string | null
30
+ readonly then: string | null
31
+ readonly mailer: JobMailer | null
29
32
  }
30
33
 
31
34
  export type SiteFunction = (args: { readonly input?: unknown; readonly ctx: object }) => unknown
@@ -53,8 +56,14 @@ export function isSiteHandler(node: unknown): node is SiteHandler {
53
56
  return typeof node === 'object' && node !== null && sites.has(node)
54
57
  }
55
58
 
56
- const DECLARED_FIELDS: readonly string[] = ['sql', 'unscoped', 'connect', 'issues']
57
- const NO_DECLARATION: SiteDeclaration = Object.freeze({ sql: null, unscoped: Object.freeze({}), connect: Object.freeze([]), issues: null })
59
+ const NO_DECLARATION: SiteDeclaration = Object.freeze({
60
+ sql: null,
61
+ unscoped: Object.freeze({}),
62
+ connect: Object.freeze([]),
63
+ issues: null,
64
+ then: null,
65
+ mailer: null,
66
+ })
58
67
 
59
68
  function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
60
69
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -81,7 +90,7 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
81
90
  return NO_DECLARATION
82
91
  }
83
92
  for (const key of Object.keys(declared)) {
84
- if (!DECLARED_FIELDS.includes(key)) {
93
+ if (!DECLARED_FIELDS.some((field) => field === key)) {
85
94
  problems.push(`${where}: '${key}' is no declaration (declarations: ${DECLARED_FIELDS.join(', ')})`)
86
95
  }
87
96
  }
@@ -122,11 +131,22 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
122
131
  if (issues !== undefined && typeof issues !== 'string') {
123
132
  problems.push(`${where}: issues is not the name of a link mount`)
124
133
  }
134
+ const then = ownField(declared, 'then')
135
+ if (then !== undefined && typeof then !== 'string') {
136
+ problems.push(`${where}: then is not the name of a job`)
137
+ }
138
+ const mailerField = ownField(declared, 'mailer')
139
+ const mailer = JOB_MAILERS.find((transport) => transport === mailerField) ?? null
140
+ if (mailerField !== undefined && mailer === null) {
141
+ problems.push(`${where}: mailer is not 'installation' or 'own'`)
142
+ }
125
143
  return Object.freeze({
126
144
  sql: sql === 'read' || sql === 'write' ? sql : null,
127
145
  unscoped: Object.freeze(unscoped),
128
146
  connect: Object.freeze(connectors),
129
147
  issues: typeof issues === 'string' ? issues : null,
148
+ then: typeof then === 'string' ? then : null,
149
+ mailer,
130
150
  })
131
151
  }
132
152
 
@@ -167,13 +187,25 @@ export function approvedReach(site: string, artifact: ContractArtifact): SiteDec
167
187
  unscoped: Object.freeze({ ...reach.unscoped }),
168
188
  connect: Object.freeze([...reach.connect]),
169
189
  issues: reach.issues,
190
+ then: reach.then ?? null,
191
+ mailer: reach.mailer ?? null,
170
192
  })
171
193
  }
172
194
 
173
- /** A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted. */
195
+ /**
196
+ * A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted; `then` and
197
+ * `mailer` only when declared.
198
+ */
174
199
  function describeDeclaration(declaration: SiteDeclaration): string {
175
200
  const unscoped = Object.entries(declaration.unscoped).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
176
- return JSON.stringify({ sql: declaration.sql, unscoped, connect: [...declaration.connect].sort(), issues: declaration.issues })
201
+ return JSON.stringify({
202
+ sql: declaration.sql,
203
+ unscoped,
204
+ connect: [...declaration.connect].sort(),
205
+ issues: declaration.issues,
206
+ ...(declaration.then === null ? {} : { then: declaration.then }),
207
+ ...(declaration.mailer === null ? {} : { mailer: declaration.mailer }),
208
+ })
177
209
  }
178
210
 
179
211
  /**
@@ -1,6 +1,7 @@
1
1
  // `serve(contract, { database, deps })`: how `src/app.ts` serves a contract of the staged kit. Its handlers sit
2
2
  // beside their endpoints, so there is no handler tree to pass: `serve` collects the one `boot` binds by endpoint id.
3
3
 
4
+ import type { EmailSender } from '@fabrika/email'
4
5
  import { AsyncLocalStorage } from 'node:async_hooks'
5
6
  import { isProcedure, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
6
7
  import type { AnyContract, Schema } from '../contract/define'
@@ -61,6 +62,8 @@ export interface ServeOptions {
61
62
  /** `scoped(tables)` of `@fabrika/db`, which `boot` binds, or `null` for a kit without tables (`defineApp(vocabulary)`). */
62
63
  readonly database: ImplementationDatabase | null
63
64
  readonly deps?: AppDeps<unknown>
65
+ /** The transport of the jobs that declare `mailer: 'own'`, built once at boot from the application's own variables. */
66
+ readonly mail?: (env: ApplicationEnvironment) => EmailSender | null
64
67
  }
65
68
 
66
69
  /**
@@ -86,6 +89,7 @@ export function serve<const C extends AnyContract>(contract: C, options: ServeOp
86
89
  database: options.database,
87
90
  connect: snapshotsOf(contract),
88
91
  handlers: (env, runtime) => handlerTree(contract, build?.(env, runtime) ?? ((work) => work())),
92
+ ...(options.mail === undefined ? {} : { mail: options.mail }),
89
93
  })
90
94
  }
91
95
 
@@ -0,0 +1,261 @@
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.
6
+
7
+ import { type ContractArtifact, JOB_SITE_PREFIX, type JobMailer, sha256Hex } from '@fabrika/auth-core'
8
+ import {
9
+ EMAIL_PROVIDER_VARIABLE,
10
+ EmailDeliveryError,
11
+ type EmailDeliveryResult,
12
+ type EmailMessage,
13
+ type EmailProviderRegistry,
14
+ type EmailSender,
15
+ emailSenderFromEnv,
16
+ mayRetryDelivery,
17
+ NO_EMAIL_PROVIDER,
18
+ pickEmailEnvironment,
19
+ } from '@fabrika/email'
20
+ import { z } from 'zod/v4'
21
+ import type { ApplicationEnvironment } from '../implement/implement'
22
+ import type { AppMail, AppMailMessage } from './types'
23
+
24
+ export const MAX_APP_MAIL_KEY_LENGTH = 200
25
+ const PREFIX_DIGEST_LENGTH = 32
26
+ const APP_MAIL_KEY = new RegExp(`^[\\x21-\\x7E]{1,${MAX_APP_MAIL_KEY_LENGTH}}$`)
27
+
28
+ /** Whose keys a prefix separates: the installation, by IAM's public origin, and the application environment in it. */
29
+ export interface MailKeyScope {
30
+ /** `FABRIKA_IAM_ISSUER` as an origin: it tells installations, and the local stack, apart. */
31
+ readonly issuer: string
32
+ /** `FABRIKA_IAM_APP_ID`, `<app>@<env>`. */
33
+ readonly appId: string
34
+ }
35
+
36
+ /**
37
+ * `fabrika:<the first 32 hex of sha256('<issuer>\n<app>@<env>')>:` — 41 characters, whatever the names hold. Renaming
38
+ * IAM's domain changes every key once, so a message in flight across the rename may be sent again.
39
+ */
40
+ export function mailKeyPrefix(scope: MailKeyScope): string {
41
+ return `fabrika:${sha256Hex(`${scope.issuer}\n${scope.appId}`).slice(0, PREFIX_DIGEST_LENGTH)}:`
42
+ }
43
+
44
+ /** The key the transport receives for the application's `key`. */
45
+ export function transportMailKey(scope: MailKeyScope, key: string): string {
46
+ return `${mailKeyPrefix(scope)}${key}`
47
+ }
48
+
49
+ /** A mailer job's transport: one that sends, or the stated absence of one, whose every send fails `mail_not_configured`. */
50
+ export interface JobMailTransport {
51
+ readonly sender: EmailSender
52
+ readonly available: boolean
53
+ }
54
+
55
+ /** The transports of a contract's mailer jobs, resolved once by `boot`; `null` where no job declares that mailer. */
56
+ export interface MailTransports {
57
+ readonly installation: JobMailTransport | null
58
+ readonly own: JobMailTransport | null
59
+ readonly keyPrefix: string
60
+ }
61
+
62
+ /** The test kit's transport for every mailer job: a stand-in sender, or `'none'` for an installation without mail. */
63
+ export type MailStandIn = EmailSender | 'none'
64
+
65
+ /** The application's own transport, built once at boot from its own variables; `null` when they configure none. */
66
+ export type OwnMailTransport = (env: ApplicationEnvironment) => EmailSender | null
67
+
68
+ export interface JobMailInput {
69
+ readonly transport: JobMailTransport
70
+ readonly keyPrefix: string
71
+ /** The attempt's `ctx.signal`: it aborts every send of the attempt. */
72
+ readonly signal: AbortSignal
73
+ /** Throws once the attempt has ended. */
74
+ readonly assertLive: () => void
75
+ }
76
+
77
+ export function jobMail(input: JobMailInput): AppMail {
78
+ const { transport: { sender, available }, keyPrefix, signal: attempt, assertLive } = input
79
+ return Object.freeze({
80
+ available,
81
+ capabilities: sender.capabilities,
82
+ async send(message: AppMailMessage, signal?: AbortSignal): Promise<EmailDeliveryResult> {
83
+ assertLive()
84
+ const key: unknown = message.idempotencyKey
85
+ if (typeof key !== 'string' || !APP_MAIL_KEY.test(key)) {
86
+ throw new EmailDeliveryError(`Email idempotency key is not 1..${MAX_APP_MAIL_KEY_LENGTH} visible ASCII characters`, { code: 'invalid_message' })
87
+ }
88
+ const transported: EmailMessage = {
89
+ to: message.to,
90
+ subject: message.subject,
91
+ text: message.text,
92
+ ...(message.html === undefined ? {} : { html: message.html }),
93
+ idempotencyKey: `${keyPrefix}${key}`,
94
+ ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
95
+ ...(message.fromName === undefined ? {} : { fromName: message.fromName }),
96
+ }
97
+ try {
98
+ return await sender.send(transported, signal === undefined ? attempt : AbortSignal.any([attempt, signal]))
99
+ } catch (error) {
100
+ if (error instanceof EmailDeliveryError) {
101
+ throw error
102
+ }
103
+ // An application's own transport may throw anything, and its message may quote a credential: it is dropped unread.
104
+ throw new EmailDeliveryError('The transport failed', { code: 'provider_unavailable' })
105
+ }
106
+ },
107
+ mayRetry: (error: unknown): boolean => error instanceof EmailDeliveryError && mayRetryDelivery(error, sender.capabilities),
108
+ })
109
+ }
110
+
111
+ export interface MailTransportInput {
112
+ readonly artifact: ContractArtifact
113
+ /** The framework's variables, whose `FABRIKA_EMAIL_*` configure the installation's transport. */
114
+ readonly framework: Readonly<Record<string, string>>
115
+ readonly application: ApplicationEnvironment
116
+ /** `serve(contract, { mail })`. */
117
+ readonly own: OwnMailTransport | undefined
118
+ /** The test kit's transport for every mailer job; the variables and `own` are then not read. */
119
+ readonly standIn: MailStandIn | undefined
120
+ readonly registry: EmailProviderRegistry
121
+ /** Whose keys the prefix separates. */
122
+ readonly keyScope: MailKeyScope
123
+ /** A local composition: a missing transport is no mail, as `FABRIKA_EMAIL_PROVIDER=none` is, instead of a refused boot. */
124
+ readonly local: boolean
125
+ }
126
+
127
+ /** The transports of the contract's mailer jobs, or why one of them cannot send, added to `problems`. */
128
+ export function resolveMailTransports(input: MailTransportInput, problems: string[]): MailTransports {
129
+ const keyPrefix = mailKeyPrefix(input.keyScope)
130
+ const jobs = mailerJobs(input.artifact)
131
+ if (input.own === undefined) {
132
+ for (const name of jobs.own) {
133
+ problems.push(`job '${name}' declares mailer: 'own', and serve(contract, { … }) has no mail: pass mail: (env) => EmailSender`)
134
+ }
135
+ } else if (jobs.own.length === 0) {
136
+ problems.push(`serve(contract, { mail }) builds a transport no job uses: declare mailer: 'own' on the job that sends, or remove mail`)
137
+ }
138
+ if (input.standIn !== undefined) {
139
+ const standIn = input.standIn === 'none' ? NO_MAIL : { sender: input.standIn, available: true }
140
+ return {
141
+ installation: jobs.installation.length === 0 ? null : standIn,
142
+ own: jobs.own.length === 0 ? null : standIn,
143
+ keyPrefix,
144
+ }
145
+ }
146
+ return {
147
+ installation: jobs.installation.length === 0 ? null : installationTransport(jobs.installation, input, problems),
148
+ own: jobs.own.length === 0 ? null : ownTransport(jobs.own, input, problems),
149
+ keyPrefix,
150
+ }
151
+ }
152
+
153
+ /** The names of the jobs that declare each mailer, by the approved artifact. */
154
+ function mailerJobs(artifact: ContractArtifact): Readonly<Record<JobMailer, readonly string[]>> {
155
+ const jobs: Record<JobMailer, string[]> = { installation: [], own: [] }
156
+ for (const name of Object.keys(artifact.jobs)) {
157
+ const mailer = artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.mailer
158
+ if (mailer !== undefined) {
159
+ jobs[mailer].push(name)
160
+ }
161
+ }
162
+ return jobs
163
+ }
164
+
165
+ function installationTransport(names: readonly string[], input: MailTransportInput, problems: string[]): JobMailTransport | null {
166
+ const env = pickEmailEnvironment(input.framework)
167
+ let sender: EmailSender | null
168
+ try {
169
+ sender = emailSenderFromEnv(env, input.registry)
170
+ } catch (error) {
171
+ if (!(error instanceof EmailDeliveryError)) {
172
+ throw error
173
+ }
174
+ // Its message names variables, never their values; a local installation is refused too, as it is misconfigured.
175
+ problems.push(`the installation's mail transport: ${error.message}`)
176
+ return null
177
+ }
178
+ if (sender !== null) {
179
+ return { sender, available: true }
180
+ }
181
+ if (env[EMAIL_PROVIDER_VARIABLE] === NO_EMAIL_PROVIDER || input.local) {
182
+ return noMail(names, 'installation')
183
+ }
184
+ // Control writes the variable on every deploy that needs it, `none` included: its absence means no deploy wrote it.
185
+ for (const name of names) {
186
+ problems.push(
187
+ `job '${name}' declares mailer: 'installation', and FABRIKA_EMAIL_PROVIDER is not set: Control writes it when it deploys the application (none for an installation without a transport)`,
188
+ )
189
+ }
190
+ return null
191
+ }
192
+
193
+ function ownTransport(names: readonly string[], input: MailTransportInput, problems: string[]): JobMailTransport | null {
194
+ if (input.own === undefined) {
195
+ return null
196
+ }
197
+ let built: unknown
198
+ try {
199
+ built = input.own(input.application)
200
+ } catch (error) {
201
+ if (!(error instanceof EmailDeliveryError)) {
202
+ throw error
203
+ }
204
+ problems.push(`serve's mail: ${error.message}`)
205
+ return null
206
+ }
207
+ const sender = parseSender(built)
208
+ if (sender === undefined) {
209
+ problems.push("serve's mail returned no EmailSender (capabilities and send(message, signal))")
210
+ return null
211
+ }
212
+ if (sender !== null) {
213
+ return { sender, available: true }
214
+ }
215
+ if (input.local) {
216
+ return noMail(names, 'own')
217
+ }
218
+ for (const name of names) {
219
+ problems.push(`job '${name}' declares mailer: 'own', and serve's mail built no transport from the application's variables`)
220
+ }
221
+ return null
222
+ }
223
+
224
+ const SenderCapabilities = z.object({ idempotent: z.boolean(), confirmation: z.enum(['sync', 'queued']), maxBatch: z.int().positive() })
225
+
226
+ /** What `serve`'s `mail` returned: a sender, `null` for none, `undefined` for anything else. */
227
+ function parseSender(value: unknown): EmailSender | null | undefined {
228
+ if (value === null) {
229
+ return null
230
+ }
231
+ if (typeof value !== 'object') {
232
+ return undefined
233
+ }
234
+ const send: unknown = Reflect.get(value, 'send')
235
+ const capabilities = SenderCapabilities.safeParse(Reflect.get(value, 'capabilities'))
236
+ if (typeof send !== 'function' || !capabilities.success) {
237
+ return undefined
238
+ }
239
+ return Object.freeze({
240
+ capabilities: Object.freeze(capabilities.data),
241
+ send: (message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> => Reflect.apply(send, value, [message, signal]),
242
+ })
243
+ }
244
+
245
+ /** Where the installation has no transport: every send fails `not_sent`, and the boot does not. */
246
+ export const NO_MAIL: JobMailTransport = Object.freeze({
247
+ sender: Object.freeze({
248
+ capabilities: Object.freeze({ idempotent: true, confirmation: 'sync', maxBatch: 1 }),
249
+ send: () => Promise.reject(new EmailDeliveryError('This installation has no mail transport', { code: 'mail_not_configured' })),
250
+ }),
251
+ available: false,
252
+ })
253
+
254
+ function noMail(names: readonly string[], kind: JobMailer): JobMailTransport {
255
+ for (const name of names) {
256
+ console.warn(
257
+ `fabrika: job '${name}' sends mail, and this installation has no ${kind} mail transport: ctx.mail.available is false, and every send fails with mail_not_configured`,
258
+ )
259
+ }
260
+ return NO_MAIL
261
+ }