@fabrika/app 0.8.2 → 0.9.0-cloudflare.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/package.json +40 -24
  2. package/skill/SKILL.md +29 -3
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/dev.md +2 -1
  5. package/skill/references/endpoint.md +12 -3
  6. package/skill/references/errors.md +39 -35
  7. package/skill/references/files.md +4 -2
  8. package/skill/references/jobs.md +9 -0
  9. package/skill/references/table-and-migration.md +19 -2
  10. package/skill/references/tests.md +33 -1
  11. package/skill/references/webhooks-links-connectors.md +1 -1
  12. package/src/build-recipe.ts +198 -0
  13. package/src/build.ts +60 -4
  14. package/src/bun.ts +33 -8
  15. package/src/cloudflare/application.ts +352 -0
  16. package/src/cloudflare/bindings.ts +159 -0
  17. package/src/cloudflare/names.ts +16 -0
  18. package/src/cloudflare/ops.ts +246 -0
  19. package/src/cloudflare/r2-files.ts +172 -0
  20. package/src/cloudflare.ts +99 -146
  21. package/src/contract/app-id.ts +5 -0
  22. package/src/contract/artifact-schema.ts +8 -1
  23. package/src/contract/build.ts +68 -11
  24. package/src/contract/compile.ts +1 -0
  25. package/src/contract/context.ts +9 -0
  26. package/src/contract/declaration.ts +2 -0
  27. package/src/contract/define.ts +28 -2
  28. package/src/contract/group.ts +10 -1
  29. package/src/contract/kit.ts +6 -2
  30. package/src/contract/platform.ts +3 -2
  31. package/src/contract/vocabulary.ts +26 -8
  32. package/src/dev/changes.ts +35 -0
  33. package/src/dev/contract-digest.ts +17 -0
  34. package/src/dev/index.ts +11 -0
  35. package/src/dev/launcher.ts +713 -0
  36. package/src/dev/output.ts +72 -0
  37. package/src/dev/wire.ts +136 -0
  38. package/src/disk-assets.ts +8 -3
  39. package/src/files/archive.ts +2 -2
  40. package/src/files/job-names.ts +12 -0
  41. package/src/files/kinds.ts +19 -7
  42. package/src/files/scan.ts +3 -4
  43. package/src/files/streams.ts +3 -1
  44. package/src/implement/anonymous.ts +11 -1
  45. package/src/implement/boot.ts +70 -7
  46. package/src/implement/bounded.ts +21 -2
  47. package/src/implement/capability.ts +18 -3
  48. package/src/implement/dispatch.ts +251 -147
  49. package/src/implement/egress.ts +341 -8
  50. package/src/implement/failures.ts +17 -1
  51. package/src/implement/faults.ts +1 -0
  52. package/src/implement/index.ts +1 -0
  53. package/src/implement/invocation.ts +18 -9
  54. package/src/implement/principal.ts +25 -2
  55. package/src/implement/terminals.ts +93 -0
  56. package/src/implement/workload.ts +24 -13
  57. package/src/jobs/report.ts +5 -1
  58. package/src/jobs/runner.ts +40 -19
  59. package/src/jobs/schedule.ts +7 -1
  60. package/src/jobs/scrub.ts +14 -1
  61. package/src/spa-fallback.ts +12 -0
  62. package/src/testing/endpoint-access.ts +307 -152
  63. package/src/testing/isolation.ts +8 -1
  64. package/src/testing/kit.ts +20 -2
  65. package/src/testing/proxy.ts +5 -5
  66. package/types/src/agent-skill.d.ts +29 -0
  67. package/types/src/build-recipe.d.ts +70 -0
  68. package/types/src/build.d.ts +29 -0
  69. package/types/src/bun.d.ts +66 -0
  70. package/types/src/client/caller.d.ts +19 -0
  71. package/types/src/client/contract.d.ts +131 -0
  72. package/types/src/client/error-reporting.d.ts +66 -0
  73. package/types/src/client/index.d.ts +4 -0
  74. package/types/src/client/rpc.d.ts +14 -0
  75. package/types/src/client/share.d.ts +2 -0
  76. package/types/src/client/transport.d.ts +42 -0
  77. package/types/src/cloudflare/application.d.ts +42 -0
  78. package/types/src/cloudflare/bindings.d.ts +33 -0
  79. package/types/src/cloudflare/names.d.ts +11 -0
  80. package/types/src/cloudflare/ops.d.ts +46 -0
  81. package/types/src/cloudflare/r2-files.d.ts +9 -0
  82. package/types/src/cloudflare.d.ts +42 -0
  83. package/types/src/contract/app-id.d.ts +2 -0
  84. package/types/src/contract/artifact-schema.d.ts +8 -0
  85. package/types/src/contract/attachment.d.ts +41 -0
  86. package/types/src/contract/build.d.ts +12 -0
  87. package/types/src/contract/compile.d.ts +11 -0
  88. package/types/src/contract/connect.d.ts +51 -0
  89. package/types/src/contract/context.d.ts +510 -0
  90. package/types/src/contract/declaration.d.ts +307 -0
  91. package/types/src/contract/defaults.d.ts +208 -0
  92. package/types/src/contract/define.d.ts +376 -0
  93. package/types/src/contract/file.d.ts +44 -0
  94. package/types/src/contract/group.d.ts +282 -0
  95. package/types/src/contract/handled.d.ts +18 -0
  96. package/types/src/contract/index.d.ts +19 -0
  97. package/types/src/contract/kit.d.ts +211 -0
  98. package/types/src/contract/platform.d.ts +9 -0
  99. package/types/src/contract/problems.d.ts +14 -0
  100. package/types/src/contract/redirect.d.ts +25 -0
  101. package/types/src/contract/schema-from-shape.d.ts +10 -0
  102. package/types/src/contract/schema-shape.d.ts +5 -0
  103. package/types/src/contract/snapshot-source.d.ts +15 -0
  104. package/types/src/contract/token.d.ts +58 -0
  105. package/types/src/contract/vocabulary.d.ts +32 -0
  106. package/types/src/contract/webhook-binding.d.ts +24 -0
  107. package/types/src/contract/webhook-scheme.d.ts +23 -0
  108. package/types/src/dev/changes.d.ts +7 -0
  109. package/types/src/dev/contract-digest.d.ts +1 -0
  110. package/types/src/dev/index.d.ts +2 -0
  111. package/types/src/dev/launcher.d.ts +32 -0
  112. package/types/src/dev/output.d.ts +28 -0
  113. package/types/src/dev/wire.d.ts +126 -0
  114. package/types/src/disk-assets.d.ts +6 -0
  115. package/types/src/errors.d.ts +46 -0
  116. package/types/src/fetch.d.ts +7 -0
  117. package/types/src/files/archive.d.ts +26 -0
  118. package/types/src/files/clamscan.d.ts +32 -0
  119. package/types/src/files/handle.d.ts +106 -0
  120. package/types/src/files/job-names.d.ts +9 -0
  121. package/types/src/files/kinds.d.ts +101 -0
  122. package/types/src/files/scan.d.ts +78 -0
  123. package/types/src/files/sniff.d.ts +7 -0
  124. package/types/src/files/streams.d.ts +17 -0
  125. package/types/src/files/treatment.d.ts +18 -0
  126. package/types/src/from-row.d.ts +20 -0
  127. package/types/src/honeypot.d.ts +2 -0
  128. package/types/src/implement/anonymous.d.ts +28 -0
  129. package/types/src/implement/attachment.d.ts +14 -0
  130. package/types/src/implement/boot.d.ts +120 -0
  131. package/types/src/implement/bounded.d.ts +32 -0
  132. package/types/src/implement/browser-config.d.ts +19 -0
  133. package/types/src/implement/capability.d.ts +144 -0
  134. package/types/src/implement/challenge.d.ts +75 -0
  135. package/types/src/implement/client-address.d.ts +11 -0
  136. package/types/src/implement/connect.d.ts +67 -0
  137. package/types/src/implement/crashes.d.ts +31 -0
  138. package/types/src/implement/dispatch.d.ts +221 -0
  139. package/types/src/implement/egress.d.ts +49 -0
  140. package/types/src/implement/environment.d.ts +14 -0
  141. package/types/src/implement/failures.d.ts +39 -0
  142. package/types/src/implement/faults.d.ts +45 -0
  143. package/types/src/implement/headers.d.ts +7 -0
  144. package/types/src/implement/implement.d.ts +58 -0
  145. package/types/src/implement/index.d.ts +25 -0
  146. package/types/src/implement/invocation.d.ts +125 -0
  147. package/types/src/implement/membership.d.ts +201 -0
  148. package/types/src/implement/multipart.d.ts +36 -0
  149. package/types/src/implement/params.d.ts +7 -0
  150. package/types/src/implement/principal.d.ts +57 -0
  151. package/types/src/implement/project.d.ts +11 -0
  152. package/types/src/implement/public.d.ts +61 -0
  153. package/types/src/implement/serve.d.ts +25 -0
  154. package/types/src/implement/terminals.d.ts +33 -0
  155. package/types/src/implement/tokens.d.ts +49 -0
  156. package/types/src/implement/types.d.ts +243 -0
  157. package/types/src/implement/urls.d.ts +15 -0
  158. package/types/src/implement/verifiers.d.ts +19 -0
  159. package/types/src/implement/webhook.d.ts +55 -0
  160. package/types/src/implement/workload.d.ts +56 -0
  161. package/types/src/implement/xlsx.d.ts +6 -0
  162. package/types/src/index.d.ts +12 -0
  163. package/types/src/jobs/exception.d.ts +40 -0
  164. package/types/src/jobs/mail.d.ts +67 -0
  165. package/types/src/jobs/report.d.ts +56 -0
  166. package/types/src/jobs/runner.d.ts +154 -0
  167. package/types/src/jobs/schedule.d.ts +17 -0
  168. package/types/src/jobs/scrub.d.ts +12 -0
  169. package/types/src/jobs/types.d.ts +98 -0
  170. package/types/src/react/caller.d.ts +30 -0
  171. package/types/src/react/errors.d.ts +40 -0
  172. package/types/src/react/hooks.d.ts +110 -0
  173. package/types/src/react/index.d.ts +5 -0
  174. package/types/src/react/provider.d.ts +58 -0
  175. package/types/src/react/store.d.ts +68 -0
  176. package/types/src/react/testing.d.ts +28 -0
  177. package/types/src/react/types.d.ts +33 -0
  178. package/types/src/rpc/builder.d.ts +52 -0
  179. package/types/src/rpc/contract.d.ts +23 -0
  180. package/types/src/rpc/dispatcher.d.ts +11 -0
  181. package/types/src/rpc/types.d.ts +62 -0
  182. package/types/src/service/define.d.ts +42 -0
  183. package/types/src/service/index.d.ts +3 -0
  184. package/types/src/service/serve.d.ts +28 -0
  185. package/types/src/spa-fallback.d.ts +2 -0
  186. package/types/src/standard-schema.d.ts +55 -0
  187. package/types/src/testing/caller.d.ts +95 -0
  188. package/types/src/testing/challenge.d.ts +11 -0
  189. package/types/src/testing/dev.d.ts +97 -0
  190. package/types/src/testing/endpoint-access.d.ts +131 -0
  191. package/types/src/testing/index.d.ts +12 -0
  192. package/types/src/testing/isolation.d.ts +3 -0
  193. package/types/src/testing/kit.d.ts +187 -0
  194. package/types/src/testing/membership.d.ts +132 -0
  195. package/types/src/testing/policy-isolation.d.ts +113 -0
  196. package/types/src/testing/preload.d.ts +1 -0
  197. package/types/src/testing/proxy.d.ts +40 -0
  198. package/types/src/testing/runner.d.ts +8 -0
  199. package/types/src/testing/sample-file.d.ts +5 -0
  200. package/types/src/testing/status.d.ts +4 -0
  201. package/types/src/undumpable.d.ts +5 -0
@@ -0,0 +1,72 @@
1
+ // The launcher's view of output: every line of the application and of the commands it runs is forwarded to its own
2
+ // stream, as Zerops' runtime log reads it, and the newest lines are kept to report. Secret values never reach either.
3
+
4
+ import { DEV_REPORT_MAX_LINE_CHARACTERS, type DevLogLine } from './wire.js'
5
+
6
+ export type OutputStream = DevLogLine['stream']
7
+
8
+ export interface OutputSink {
9
+ write(stream: OutputStream, line: string): void
10
+ }
11
+
12
+ /** The launcher process's own stdout and stderr. */
13
+ export const processOutput: OutputSink = {
14
+ write(stream, line) {
15
+ ;(stream === 'stdout' ? process.stdout : process.stderr).write(`${line}\n`)
16
+ },
17
+ }
18
+
19
+ /**
20
+ * The newest `capacity` lines, numbered from 0 since the launcher started, with every known secret value replaced. A
21
+ * mark starts the lines a report carries: those of the newest sync or reset, and the output after it.
22
+ */
23
+ export class OutputRing {
24
+ private readonly lines: DevLogLine[] = []
25
+ private next = 0
26
+ private marked = 0
27
+
28
+ constructor(private readonly capacity: number, private readonly secrets: readonly string[], private readonly sink: OutputSink) {}
29
+
30
+ push(stream: OutputStream, text: string): void {
31
+ const line = this.redact(text)
32
+ this.sink.write(stream, line)
33
+ this.lines.push({ n: this.next, stream, text: line.slice(0, DEV_REPORT_MAX_LINE_CHARACTERS) })
34
+ this.next += 1
35
+ if (this.lines.length > this.capacity) this.lines.shift()
36
+ }
37
+
38
+ /** Report from the next line on: the lines before it belong to an older head. */
39
+ mark(): void {
40
+ this.marked = this.next
41
+ }
42
+
43
+ /** The newest `count` lines since the mark, oldest first. */
44
+ tail(count: number): DevLogLine[] {
45
+ const since = this.lines.filter((line) => line.n >= this.marked)
46
+ return since.slice(Math.max(0, since.length - count))
47
+ }
48
+
49
+ private redact(text: string): string {
50
+ let line = text
51
+ for (const secret of this.secrets) line = line.replaceAll(secret, '[redacted]')
52
+ return line
53
+ }
54
+ }
55
+
56
+ /** Feed `stream` to `onLine` one line at a time; a last line without a newline is fed when the stream ends. */
57
+ export async function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> {
58
+ const decoder = new TextDecoder()
59
+ const reader = stream.getReader()
60
+ let pending = ''
61
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
62
+ pending += decoder.decode(read.value, { stream: true })
63
+ let end = pending.indexOf('\n')
64
+ while (end !== -1) {
65
+ onLine(pending.slice(0, end).replace(/\r$/, ''))
66
+ pending = pending.slice(end + 1)
67
+ end = pending.indexOf('\n')
68
+ }
69
+ }
70
+ pending += decoder.decode()
71
+ if (pending !== '') onLine(pending)
72
+ }
@@ -0,0 +1,136 @@
1
+ // The wire of the platform transport's source receiver (ADR-0064): what the launcher of a `dev` environment asks
2
+ // Control and what it reports back. Both sides import it, so the shapes and the one parser of each message cannot drift
3
+ // apart. Runtime-neutral: zod and Web APIs only.
4
+
5
+ import { z } from 'zod/v4'
6
+
7
+ /** The long poll under the receiver's URL (`FABRIKA_PLATFORM_SOURCE_URL`). */
8
+ export const DEV_SOURCE_WAIT_PATH = '/platform/source/wait'
9
+ /** Where the launcher reports what it serves. */
10
+ export const DEV_SOURCE_REPORT_PATH = '/platform/source/report'
11
+
12
+ /** How long Control holds a wait before it answers `idle`: below every proxy's idle timeout on the way. */
13
+ export const DEV_SOURCE_WAIT_MS = 25_000
14
+
15
+ /** The most log lines one report carries, and the longest line. */
16
+ export const DEV_REPORT_MAX_LINES = 200
17
+ export const DEV_REPORT_MAX_LINE_CHARACTERS = 2_000
18
+ export const DEV_REPORT_MAX_MESSAGE_CHARACTERS = 500
19
+
20
+ const commitSha = z.string().regex(/^[0-9a-f]{40}$/)
21
+ const generation = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
22
+
23
+ /**
24
+ * Repository-relative with forward slashes: no leading slash, no backslash, no empty, `.` or `..` segment, no control
25
+ * character. The launcher writes these paths under its root, so nothing it receives may name a path outside it.
26
+ */
27
+ export function isSourcePath(path: string): boolean {
28
+ return path.length > 0 && path.length <= 512 && !path.startsWith('/') && !path.includes('\\')
29
+ && ![...path].some((character) => character.charCodeAt(0) <= 0x1f || character.charCodeAt(0) === 0x7f)
30
+ && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
31
+ }
32
+
33
+ const sourcePath = z.string().refine(isSourcePath, 'not a repository-relative path')
34
+
35
+ /**
36
+ * The commit the container's bootstrap run built (`DevLauncherConfig.head`). Control answers only the container of the
37
+ * newest bootstrap run: a container a newer bootstrap replaces still runs for a while, and must not sync or report.
38
+ */
39
+ const bootstrap = commitSha
40
+
41
+ export const devWaitRequestSchema = z.object({
42
+ bootstrap,
43
+ /** The commit whose files the container holds now. */
44
+ head: commitSha,
45
+ /** The reset generation it applied last; null for a container with no record of one, which adopts the answer's. */
46
+ reset: generation.nullable(),
47
+ /** The check request it ran last; null as for `reset`. */
48
+ checks: generation.nullable(),
49
+ /** False at boot: answer at once, so the launcher reconciles before it serves. */
50
+ hold: z.boolean(),
51
+ }).strict()
52
+
53
+ export type DevWaitRequest = z.infer<typeof devWaitRequestSchema>
54
+
55
+ /** One changed file at the target commit; `content` null deletes it. */
56
+ export const devSourceFileSchema = z.object({ path: sourcePath, content: z.string().nullable() }).strict()
57
+
58
+ export type DevSourceFile = z.infer<typeof devSourceFileSchema>
59
+
60
+ /** The files that bring the container from `from` to `to`. */
61
+ export const devSyncSchema = z.object({ from: commitSha, to: commitSha, files: z.array(devSourceFileSchema) }).strict()
62
+
63
+ export type DevSync = z.infer<typeof devSyncSchema>
64
+
65
+ /**
66
+ * What to do. `sync` is null when the container holds the recorded head (or a head it refused). `reset` and `checks` are
67
+ * the environment's current generations: one past what the container applied asks it to reset its schema, or to run
68
+ * the check chain.
69
+ */
70
+ export const devWaitAnswerSchema = z.object({ sync: devSyncSchema.nullable(), reset: generation, checks: generation }).strict()
71
+
72
+ export type DevWaitAnswer = z.infer<typeof devWaitAnswerSchema>
73
+
74
+ export const DEV_STATUSES = ['syncing', 'serving', 'failed', 'needs_bootstrap', 'resetting', 'checking'] as const
75
+
76
+ export type DevStatus = (typeof DEV_STATUSES)[number]
77
+
78
+ export const devLogLineSchema = z.object({
79
+ /** The line's number in the launcher's output since it started; it only grows. */
80
+ n: z.number().int().min(0),
81
+ stream: z.enum(['stdout', 'stderr']),
82
+ text: z.string().max(DEV_REPORT_MAX_LINE_CHARACTERS),
83
+ }).strict()
84
+
85
+ export type DevLogLine = z.infer<typeof devLogLineSchema>
86
+
87
+ export const devCheckResultSchema = z.object({
88
+ name: z.string().min(1).max(64),
89
+ ok: z.boolean(),
90
+ exitCode: z.number().int().nullable(),
91
+ durationMs: z.number().int().min(0),
92
+ /** The last lines of its output. */
93
+ output: z.array(z.string().max(DEV_REPORT_MAX_LINE_CHARACTERS)).max(DEV_REPORT_MAX_LINES),
94
+ }).strict()
95
+
96
+ export type DevCheckResult = z.infer<typeof devCheckResultSchema>
97
+
98
+ export const devReportSchema = z.object({
99
+ bootstrap,
100
+ /** The commit whose files the container holds now. */
101
+ head: commitSha,
102
+ status: z.enum(DEV_STATUSES),
103
+ /** The commit the launcher is syncing to or refused; null when it has none. */
104
+ target: commitSha.nullable(),
105
+ /** One line: why a sync was refused or failed. */
106
+ message: z.string().max(DEV_REPORT_MAX_MESSAGE_CHARACTERS).nullable(),
107
+ /** The newest lines of the application's and the launcher's output, oldest first. */
108
+ log: z.array(devLogLineSchema).max(DEV_REPORT_MAX_LINES),
109
+ reset: generation.nullable(),
110
+ /** The newest check run: the request it answers, the commit whose files it checked, and each check's result. */
111
+ checks: z.object({ request: generation, head: commitSha, results: z.array(devCheckResultSchema).max(8) }).strict().nullable(),
112
+ }).strict()
113
+
114
+ export type DevReport = z.infer<typeof devReportSchema>
115
+
116
+ /** The body of a wait as JSON text. */
117
+ export function encodeDevWaitRequest(request: DevWaitRequest): string {
118
+ return JSON.stringify(devWaitRequestSchema.parse(request))
119
+ }
120
+
121
+ /** The body of a report as JSON text. */
122
+ export function encodeDevReport(report: DevReport): string {
123
+ return JSON.stringify(devReportSchema.parse(report))
124
+ }
125
+
126
+ export type DevParse<T> = { readonly ok: true; readonly value: T } | { readonly ok: false }
127
+
128
+ function parseWith<T>(schema: z.ZodType<T>, json: unknown): DevParse<T> {
129
+ const parsed = schema.safeParse(json)
130
+ return parsed.success ? { ok: true, value: parsed.data } : { ok: false }
131
+ }
132
+
133
+ /** The one parser of a wait request; a field this release does not know is refused, never dropped. */
134
+ export const parseDevWaitRequest = (json: unknown): DevParse<DevWaitRequest> => parseWith(devWaitRequestSchema, json)
135
+ export const parseDevWaitAnswer = (json: unknown): DevParse<DevWaitAnswer> => parseWith(devWaitAnswerSchema, json)
136
+ export const parseDevReport = (json: unknown): DevParse<DevReport> => parseWith(devReportSchema, json)
@@ -4,8 +4,12 @@
4
4
  import { rawRequestTarget } from '@fabrika/auth-core'
5
5
  import { join, resolve, sep } from 'node:path'
6
6
  import type { SpaAssets } from './implement/dispatch.js'
7
+ import { isPageRoute } from './spa-fallback.js'
7
8
 
8
- /** Serves each SPA mount from its build directory under `root`; a path that names no file gets the SPA's `index.html`. */
9
+ /**
10
+ * Serves each SPA mount from its build directory under `root`; a page route that names no file gets the SPA's
11
+ * `index.html` (`isPageRoute`), and a missing asset is 404.
12
+ */
9
13
  export function diskAssets(root: string): SpaAssets {
10
14
  return {
11
15
  async serve(mount, request) {
@@ -15,8 +19,9 @@ export function diskAssets(root: string): SpaAssets {
15
19
  // The path is canonical, so every segment decodes and none is `.` or `..`.
16
20
  const file = join(dir, ...relative.split('/').filter((segment) => segment !== '').map((segment) => decodeURIComponent(segment)))
17
21
  const candidate = file.startsWith(`${dir}${sep}`) ? Bun.file(file) : null
18
- const served = candidate !== null && (await candidate.exists()) ? candidate : Bun.file(join(dir, 'index.html'))
19
- if (!(await served.exists())) {
22
+ const found = candidate !== null && (await candidate.exists()) ? candidate : null
23
+ const served = found ?? (isPageRoute(request) ? Bun.file(join(dir, 'index.html')) : null)
24
+ if (served === null || !(await served.exists())) {
20
25
  return new Response('Not found', { status: 404 })
21
26
  }
22
27
  return new Response(served)
@@ -4,7 +4,7 @@
4
4
  // stored or deflated entries, no encryption, a layout in which every byte belongs to an entry or the directory, sizes
5
5
  // and CRCs that match, bounded counts and sizes, and nested ZIPs to a small depth. Any other archive is refused.
6
6
 
7
- import { inflateRawSync } from 'node:zlib'
7
+ import { crc32, inflateRawSync } from 'node:zlib'
8
8
 
9
9
  export const ARCHIVE_LIMITS = {
10
10
  /** Entries of one archive, nested ones counted with their parent's. */
@@ -236,7 +236,7 @@ function unpacked(entry: Entry, data: Uint8Array, budget: Budget): Uint8Array {
236
236
  throw new Refused('malformed')
237
237
  }
238
238
  }
239
- if (content.length !== entry.uncompressed || Bun.hash.crc32(content) !== entry.crc) throw new Refused('malformed')
239
+ if (content.length !== entry.uncompressed || crc32(content) !== entry.crc) throw new Refused('malformed')
240
240
  return content
241
241
  }
242
242
 
@@ -0,0 +1,12 @@
1
+ // The framework's own file jobs, in a module of their own: the build recipe schedules them without the file stack.
2
+
3
+ /** The reserved name of the scan job: a `c.cron` job name cannot contain `-`, so no application job takes it. */
4
+ export const FILE_SCAN_JOB = 'fabrika-file-scan'
5
+ export const FILE_SCAN_SCHEDULE = '* * * * *'
6
+
7
+ /**
8
+ * The framework's sweep job: hourly, it deletes the files no declared column references and the pending ones past the
9
+ * grace, so none waits for the next request. A reserved name with `-`, which no `c.cron` name can take.
10
+ */
11
+ export const FILE_SWEEP_JOB = 'fabrika-file-sweep'
12
+ export const FILE_SWEEP_SCHEDULE = '41 * * * *'
@@ -29,13 +29,7 @@ import { bytesAreOfType, SNIFF_BYTES, SNIFFABLE_TYPES } from './sniff'
29
29
  import { cleanName, digesting, isNamed, readExactly, readHead } from './streams'
30
30
  import { FileDownload } from './treatment'
31
31
 
32
- /**
33
- * The framework's sweep job: hourly, it deletes the files no declared column references and the pending ones past the
34
- * grace, so none waits for the next request. A reserved name with `-`, which no `c.cron` name can take;
35
- * `@fabrika/provider-zerops` writes it into the crontab on this schedule (`ZEROPS_FILE_SWEEP_JOB`).
36
- */
37
- export const FILE_SWEEP_JOB = 'fabrika-file-sweep'
38
- export const FILE_SWEEP_SCHEDULE = '41 * * * *'
32
+ export { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE } from './job-names'
39
33
  export const FILE_SWEEP_TIMEOUT_MS = 10 * 60_000
40
34
  /** How long a staged file waits for its first reference before the sweep may delete it. */
41
35
  export const FILE_PENDING_GRACE_MS = 60 * 60 * 1000
@@ -67,6 +61,7 @@ export class FileWork {
67
61
  readonly holder: object
68
62
  readonly request: string
69
63
  readonly #staged: string[] = []
64
+ readonly #written = new Map<string, string>()
70
65
 
71
66
  constructor(holder: object, request: string) {
72
67
  this.holder = holder
@@ -77,9 +72,18 @@ export class FileWork {
77
72
  this.#staged.push(id)
78
73
  }
79
74
 
75
+ /** The work wrote the object of a file it staged, under a key no other file has. */
76
+ wrote(id: string, objectKey: string): void {
77
+ this.#written.set(id, objectKey)
78
+ }
79
+
80
80
  get stagedIds(): readonly string[] {
81
81
  return [...this.#staged]
82
82
  }
83
+
84
+ writtenKey(id: string): string | null {
85
+ return this.#written.get(id) ?? null
86
+ }
83
87
  }
84
88
 
85
89
  interface Kind {
@@ -188,6 +192,7 @@ export class StoredFileKinds {
188
192
  const body = digesting(stream, null)
189
193
  try {
190
194
  const { size } = await this.#store.write(objectKey, body.stream, { contentType: type, maxBytes: artifact.maxBytes })
195
+ work.wrote(id, objectKey)
191
196
  const sha256 = body.digest()
192
197
  await this.#files.written(id, { size, sha256 })
193
198
  return fileReference({ id, kind: kind.name, name, type, size, status: 'pending' }, work.holder, true)
@@ -212,6 +217,13 @@ export class StoredFileKinds {
212
217
  try {
213
218
  const objectKey = await this.#files.abandon(id, work.request)
214
219
  if (objectKey === null) {
220
+ // On SQLite the pending row is written in the work's own transaction and rolled back with it, so only the
221
+ // work knows the object; a row still there is a file something claimed, whose object stays.
222
+ const written = work.writtenKey(id)
223
+ if (written !== null && (await this.#files.find(id)) === null) {
224
+ step = 'object_delete_failed'
225
+ await this.#store.delete(written)
226
+ }
215
227
  return
216
228
  }
217
229
  step = 'object_delete_failed'
package/src/files/scan.ts CHANGED
@@ -7,13 +7,12 @@
7
7
 
8
8
  import type { QuarantinedStoredFile } from '@fabrika/db/framework'
9
9
  import type { FileObjectStore } from '@fabrika/platform'
10
+ import { createHash } from 'node:crypto'
10
11
  import type { OperationsReporter } from '../jobs/report'
11
12
  import { type ArchiveRefusal, checkArchive } from './archive'
12
13
  import { ZIP_TYPES } from './sniff'
13
14
 
14
- /** The reserved name of the scan job: a `c.cron` job name cannot contain `-`, so no application job takes it. */
15
- export const FILE_SCAN_JOB = 'fabrika-file-scan'
16
- export const FILE_SCAN_SCHEDULE = '* * * * *'
15
+ export { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE } from './job-names'
17
16
 
18
17
  export const SCAN_LIMITS = {
19
18
  /** One pass scans at most this many files, and at most `batchBytes` of them; the next pass takes the rest. */
@@ -181,7 +180,7 @@ class ScanPass {
181
180
  }
182
181
  const bytes = object.size === file.size ? await readExactly(object.body, file.size, signal) : null
183
182
  if (object.size !== file.size) await object.body.cancel()
184
- if (bytes === null || new Bun.CryptoHasher('sha256').update(bytes).digest('hex') !== file.sha256) {
183
+ if (bytes === null || createHash('sha256').update(bytes).digest('hex') !== file.sha256) {
185
184
  if (!signal.aborted) this.#states.set(file.id, { kind: 'mismatch' })
186
185
  throw new Error('the object differs from its upload')
187
186
  }
@@ -1,6 +1,8 @@
1
1
  // The byte streams of a stored file on its way in and out: the head the sniff reads, the digest and the deadline of a
2
2
  // write, the exact read of a file a site opens, and the client's file name as metadata.
3
3
 
4
+ import { createHash } from 'node:crypto'
5
+
4
6
  const MAX_NAME_CODE_POINTS = 255
5
7
 
6
8
  /** The client's file name, metadata only: at most 255 code points, never half a character, no control characters. */
@@ -62,7 +64,7 @@ export function digesting(body: ReadableStream<Uint8Array>, timeoutMs: number |
62
64
  timedOut(): boolean
63
65
  stop(): void
64
66
  } {
65
- const hasher = new Bun.CryptoHasher('sha256')
67
+ const hasher = createHash('sha256')
66
68
  let timer: ReturnType<typeof setTimeout> | undefined
67
69
  let expired = false
68
70
  const stream = body.pipeThrough(
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { LINK_BOOTSTRAP_FILE, SHARE_KEY_GLOBAL } from '../contract/defaults'
6
6
  import { HttpError } from '../errors'
7
- import { readBoundedBytes } from './bounded'
7
+ import { deadline, readBoundedBytes } from './bounded'
8
8
 
9
9
  /** The slowest body a procedure waits for, in bytes per second: its deadline is `maxBytes` at this rate, 30 s at least. */
10
10
  export const BODY_RATE_FLOOR = 16 * 1024
@@ -36,6 +36,16 @@ export async function readBoundedBody(request: Request, maxBytes: number, deadli
36
36
  return body
37
37
  }
38
38
 
39
+ /** `readBoundedBody` within the deadline `maxBytes` derives; the deadline ends with the read. */
40
+ export async function readBodyInTime(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
41
+ const timer = deadline(bodyDeadlineMs(maxBytes))
42
+ try {
43
+ return await readBoundedBody(request, maxBytes, timer.signal)
44
+ } finally {
45
+ timer.clear()
46
+ }
47
+ }
48
+
39
49
  /** What a request answers whose body did not arrive whole: nobody reads it, and no failure is reported. */
40
50
  export function abortedBody(): HttpError {
41
51
  return new HttpError(400, 'aborted', 'The body could not be read to its end')
@@ -17,13 +17,21 @@ import {
17
17
  FABRIKA_IAM_APP_ID,
18
18
  FABRIKA_PROXY_SECRET,
19
19
  type IamMembershipRpc,
20
+ type IamTerminalRpc,
20
21
  isLoopbackHost,
21
22
  type Jwks,
22
23
  parseAppEnvironmentId,
23
24
  } from '@fabrika/auth-core'
24
25
  import type { Database } from '@fabrika/db'
25
26
  import type { CapabilityAuthority } from '@fabrika/db/capability'
26
- import { bindScopedDatabase, type DataAccess, dataAccessOf, type FrameworkTables, frameworkTablesOf, isScopedDatabase } from '@fabrika/db/framework'
27
+ import {
28
+ bindScopedDatabaseProvisionally,
29
+ type DataAccess,
30
+ dataAccessOf,
31
+ type FrameworkTables,
32
+ frameworkTablesOf,
33
+ isScopedDatabase,
34
+ } from '@fabrika/db/framework'
27
35
  import type { DataKind } from '@fabrika/db/tables'
28
36
  import type { EmailProviderRegistry } from '@fabrika/email'
29
37
  import { builtinEmailProviders } from '@fabrika/email/providers'
@@ -57,7 +65,7 @@ import {
57
65
  type ServedSite,
58
66
  type SpaAssets,
59
67
  } from './dispatch'
60
- import { installEgressGuard, platformCall, platformFacility } from './egress'
68
+ import { type HostResolver, installEgressGuard, platformCall, platformFacility, publicEgressRules, type SiteEgress, siteEgress } from './egress'
61
69
  import type { CapturedEnvironment } from './environment'
62
70
  import { FailureReports } from './failures'
63
71
  import { applicationPolicy } from './headers'
@@ -101,6 +109,11 @@ export interface BootOptions {
101
109
  * creates scopes, manages members or issues share links does not boot.
102
110
  */
103
111
  readonly membership?: IamMembershipRpc
112
+ /**
113
+ * IAM's identification operations at a terminal (ADR-0067): the IAM binding on Cloudflare, `HttpIamTerminals`
114
+ * elsewhere. Without it, a contract that declares a terminal does not boot.
115
+ */
116
+ readonly terminals?: IamTerminalRpc
104
117
  /**
105
118
  * Cloudflare service bindings to the owners' proxy Workers, by binding name (`FABRIKA_CONNECT_<IMPORT>`). An import
106
119
  * without one calls its owner with `fetch`.
@@ -111,6 +124,11 @@ export interface BootOptions {
111
124
  * with a job or a webhook does not boot.
112
125
  */
113
126
  readonly workloadTokens?: WorkloadTokenSource
127
+ /**
128
+ * Resolves a host name to its addresses, which the Bun adapter supplies (`node:dns`): a cron job's public egress then
129
+ * refuses a public name of a private address before each hop. A Worker exposes no resolution and passes none.
130
+ */
131
+ readonly resolveHost?: HostResolver
114
132
  /** What posts a report to Operations' ingest; `fetch` by default. */
115
133
  readonly operationsFetch?: (request: Request) => Promise<Response>
116
134
  /** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
@@ -262,6 +280,23 @@ function readProxySecret(framework: Readonly<Record<string, string>>, required:
262
280
  return proxySecretDigest(secret)
263
281
  }
264
282
 
283
+ /** What an application's SQLite database (a Durable Object's, ADR-0065) cannot serve: raw SQL and a scanning file kind. */
284
+ function sqliteProblems(artifact: ContractArtifact, database: Database | undefined, problems: string[]): void {
285
+ if (database?.dialect !== 'sqlite') {
286
+ return
287
+ }
288
+ for (const [site, reach] of Object.entries(artifact.declared ?? {})) {
289
+ if (reach.sql !== null) {
290
+ problems.push(`'${site}' declares sql: '${reach.sql}'; ctx.sql runs only on Postgres, and this application's database is SQLite (Cloudflare)`)
291
+ }
292
+ }
293
+ for (const [name, kind] of Object.entries(artifact.fileKinds ?? {})) {
294
+ if (kind.scan === true) {
295
+ problems.push(`files '${name}' declares scan: true; no malware scanner runs on Cloudflare, so a scanning file kind is not served there`)
296
+ }
297
+ }
298
+ }
299
+
265
300
  /** What this version cannot serve yet; a contract that declares it does not boot rather than answer nothing. */
266
301
  function unservable(
267
302
  artifact: ContractArtifact,
@@ -350,6 +385,8 @@ interface BoundDatabase {
350
385
  readonly framework: FrameworkTables | null
351
386
  /** The mini-ORM behind a staged-kit site's `ctx.db`; only a `scoped(tables)` layer has it. */
352
387
  readonly data: DataAccess | null
388
+ /** Disconnects a `scoped(tables)` layer this boot bound; `null` for any other database. */
389
+ readonly release: (() => void) | null
353
390
  }
354
391
 
355
392
  /**
@@ -367,7 +404,7 @@ function contractDatabase(
367
404
  }
368
405
  if (!isScopedDatabase(database)) {
369
406
  if ('locate' in database) {
370
- return { database, framework: null, data: null }
407
+ return { database, framework: null, data: null, release: null }
371
408
  }
372
409
  problems.push('serve(contract, { database }) takes the scoped(tables) result of @fabrika/db itself, not a copy or a wrapper of it')
373
410
  return null
@@ -377,8 +414,8 @@ function contractDatabase(
377
414
  return null
378
415
  }
379
416
  try {
380
- const locate = bindScopedDatabase(database, { database: connection, capabilities, readOnly: requestIsReadOnly })
381
- return { database: { tables: database.tables, locate }, framework: frameworkTablesOf(database), data: dataAccessOf(database) }
417
+ const { locate, release } = bindScopedDatabaseProvisionally(database, { database: connection, capabilities, readOnly: requestIsReadOnly })
418
+ return { database: { tables: database.tables, locate }, framework: frameworkTablesOf(database), data: dataAccessOf(database), release }
382
419
  } catch {
383
420
  problems.push('the data layer was bound before boot; only the framework binds it, so remove @fabrika/db/framework from the application')
384
421
  return null
@@ -428,8 +465,8 @@ function boundHandler(
428
465
  }
429
466
 
430
467
  /** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
431
- function egressOf(bound: BoundHandler): { readonly egress?: readonly string[] } {
432
- return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
468
+ function egressOf(bound: BoundHandler): { readonly egress?: SiteEgress } {
469
+ return !bound.site || bound.declaration === null ? {} : { egress: siteEgress(bound.declaration.egress, null) }
433
470
  }
434
471
 
435
472
  /** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
@@ -651,11 +688,28 @@ function runtimePurposes(contract: AnyContract, artifact: ContractArtifact): Rea
651
688
  }
652
689
 
653
690
  export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
691
+ const binding: Binding = { release: null }
692
+ try {
693
+ return bootOnce(app, options, binding)
694
+ } catch (error) {
695
+ // A failed boot releases the data layer it bound, so a later boot of the same application binds it again.
696
+ binding.release?.()
697
+ throw error
698
+ }
699
+ }
700
+
701
+ /** What `bootOnce` bound: released when the boot fails, kept when it succeeds. */
702
+ interface Binding {
703
+ release: (() => void) | null
704
+ }
705
+
706
+ function bootOnce(app: ContractImplementation, options: BootOptions, binding: Binding): ContractApp {
654
707
  const problems: string[] = []
655
708
  installEgressGuard()
656
709
  // What a handler reaches through these is the platform's own request, which no site's egress covers.
657
710
  const objects = options.files === undefined ? undefined : platformFacility(options.files)
658
711
  const membershipRpc = options.membership === undefined ? undefined : platformFacility(options.membership)
712
+ const terminalRpc = options.terminals === undefined ? undefined : platformFacility(options.terminals)
659
713
  const workloadTokens = options.workloadTokens === undefined ? undefined : platformFacility(options.workloadTokens)
660
714
  const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
661
715
  const artifact = compileContract(app.contract, app.database?.tables ?? {})
@@ -673,10 +727,12 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
673
727
  const owned = new Set(Object.values(artifact.tables).flatMap((table) => table.scope.mode === 'dimension' ? [table.scope.dimension] : []))
674
728
  const containers = containersOf(artifact)
675
729
  unservable(artifact, options.assets, owned, containers, problems)
730
+ sqliteProblems(artifact, options.database, problems)
676
731
  // This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
677
732
  const issuer = createCapabilityIssuer()
678
733
  const fileKinds = storedFileKinds(artifact, app.database, objects, problems)
679
734
  const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
735
+ binding.release = bound?.release ?? null
680
736
  const framework = bound?.framework ?? null
681
737
  frameworkProblems(artifact, framework, problems)
682
738
  const database = bound?.database ?? null
@@ -848,6 +904,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
848
904
  mail,
849
905
  tokens,
850
906
  url,
907
+ publicEgress: publicEgressRules(options.environment.framework, options.resolveHost ?? null),
851
908
  }, problems)
852
909
  for (const id of jobs.declared) {
853
910
  declared.add(id)
@@ -896,6 +953,11 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
896
953
  if (site !== undefined && data === null) {
897
954
  problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
898
955
  }
956
+ if (artifact.terminal !== undefined && options.terminals === undefined) {
957
+ problems.push(
958
+ 'the contract declares a terminal, and the runtime adapter supplies no IAM transport for its identifications (the IAM binding on Cloudflare; FABRIKA_IAM_RPC_URL and FABRIKA_IAM_RPC_KEY on Bun)',
959
+ )
960
+ }
899
961
  const asking = [...endpoints.values()].find(asksIam)
900
962
  if (asking !== undefined && options.membership === undefined) {
901
963
  problems.push(
@@ -919,6 +981,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
919
981
  containers,
920
982
  assets: options.assets ?? null,
921
983
  membership,
984
+ terminals: artifact.terminal === undefined ? null : (terminalRpc ?? null),
922
985
  framework,
923
986
  mint: issuer.mint,
924
987
  capabilities: issuer.authority,
@@ -8,9 +8,28 @@
8
8
  */
9
9
  export const REQUEST_BUDGET_MS = 10_000
10
10
 
11
+ /** A signal that aborts once its time passed, and a `clear` its work calls when it settles. */
12
+ export interface Deadline {
13
+ readonly signal: AbortSignal
14
+ clear(): void
15
+ }
16
+
17
+ /**
18
+ * Aborts like `AbortSignal.timeout(ms)`, or when `parent` aborts. Its timer is cleared by `clear`, which every caller
19
+ * runs in `finally`: workerd counts a pending timer as work, so an uncleared one keeps a Durable Object awake.
20
+ */
21
+ export function deadline(ms: number, parent?: AbortSignal): Deadline {
22
+ const controller = new AbortController()
23
+ const timer = setTimeout(() => controller.abort(new DOMException('The operation timed out.', 'TimeoutError')), ms)
24
+ return {
25
+ signal: parent === undefined ? controller.signal : AbortSignal.any([parent, controller.signal]),
26
+ clear: () => clearTimeout(timer),
27
+ }
28
+ }
29
+
11
30
  /** A handler's `ctx.signal`: aborted when the client leaves (`request.signal`) or once `budgetMs` passed. */
12
- export function handlerSignal(request: Request, budgetMs: number = REQUEST_BUDGET_MS): AbortSignal {
13
- return AbortSignal.any([request.signal, AbortSignal.timeout(budgetMs)])
31
+ export function handlerSignal(request: Request, budgetMs: number = REQUEST_BUDGET_MS): Deadline {
32
+ return deadline(budgetMs, request.signal)
14
33
  }
15
34
 
16
35
  export interface BoundedRead {