@fabrika/app 0.8.2 → 0.9.0-cloudflare.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -24
- package/skill/SKILL.md +27 -3
- package/skill/references/app-setup.md +8 -8
- package/skill/references/dev.md +2 -1
- package/skill/references/endpoint.md +7 -3
- package/skill/references/errors.md +39 -35
- package/skill/references/files.md +4 -2
- package/skill/references/table-and-migration.md +19 -2
- package/skill/references/tests.md +11 -0
- package/skill/references/webhooks-links-connectors.md +1 -1
- package/src/build-recipe.ts +198 -0
- package/src/build.ts +55 -4
- package/src/bun.ts +23 -8
- package/src/cloudflare/application.ts +352 -0
- package/src/cloudflare/bindings.ts +138 -0
- package/src/cloudflare/names.ts +10 -0
- package/src/cloudflare/ops.ts +246 -0
- package/src/cloudflare/r2-files.ts +172 -0
- package/src/cloudflare.ts +99 -146
- package/src/contract/app-id.ts +5 -0
- package/src/contract/artifact-schema.ts +2 -0
- package/src/contract/build.ts +35 -1
- package/src/contract/compile.ts +1 -0
- package/src/contract/context.ts +9 -0
- package/src/contract/declaration.ts +2 -0
- package/src/contract/define.ts +28 -2
- package/src/contract/kit.ts +4 -1
- package/src/contract/platform.ts +3 -2
- package/src/contract/vocabulary.ts +26 -8
- package/src/dev/changes.ts +35 -0
- package/src/dev/contract-digest.ts +17 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +713 -0
- package/src/dev/output.ts +72 -0
- package/src/dev/wire.ts +136 -0
- package/src/files/archive.ts +2 -2
- package/src/files/job-names.ts +12 -0
- package/src/files/kinds.ts +19 -7
- package/src/files/scan.ts +3 -4
- package/src/files/streams.ts +3 -1
- package/src/implement/anonymous.ts +11 -1
- package/src/implement/boot.ts +61 -4
- package/src/implement/bounded.ts +21 -2
- package/src/implement/capability.ts +15 -3
- package/src/implement/dispatch.ts +249 -145
- package/src/implement/failures.ts +17 -1
- package/src/implement/faults.ts +1 -0
- package/src/implement/index.ts +1 -0
- package/src/implement/invocation.ts +8 -0
- package/src/implement/principal.ts +25 -2
- package/src/implement/terminals.ts +93 -0
- package/src/implement/workload.ts +24 -13
- package/src/jobs/report.ts +5 -1
- package/src/jobs/runner.ts +33 -17
- package/src/jobs/schedule.ts +7 -1
- package/src/jobs/scrub.ts +14 -1
- package/src/testing/endpoint-access.ts +180 -137
- package/src/testing/kit.ts +20 -2
- package/src/testing/proxy.ts +5 -5
- package/types/src/agent-skill.d.ts +29 -0
- package/types/src/build-recipe.d.ts +70 -0
- package/types/src/build.d.ts +29 -0
- package/types/src/bun.d.ts +66 -0
- package/types/src/client/caller.d.ts +19 -0
- package/types/src/client/contract.d.ts +131 -0
- package/types/src/client/error-reporting.d.ts +66 -0
- package/types/src/client/index.d.ts +4 -0
- package/types/src/client/rpc.d.ts +14 -0
- package/types/src/client/share.d.ts +2 -0
- package/types/src/client/transport.d.ts +42 -0
- package/types/src/cloudflare/application.d.ts +42 -0
- package/types/src/cloudflare/bindings.d.ts +27 -0
- package/types/src/cloudflare/names.d.ts +6 -0
- package/types/src/cloudflare/ops.d.ts +46 -0
- package/types/src/cloudflare/r2-files.d.ts +9 -0
- package/types/src/cloudflare.d.ts +42 -0
- package/types/src/contract/app-id.d.ts +2 -0
- package/types/src/contract/artifact-schema.d.ts +8 -0
- package/types/src/contract/attachment.d.ts +41 -0
- package/types/src/contract/build.d.ts +12 -0
- package/types/src/contract/compile.d.ts +11 -0
- package/types/src/contract/connect.d.ts +51 -0
- package/types/src/contract/context.d.ts +510 -0
- package/types/src/contract/declaration.d.ts +307 -0
- package/types/src/contract/defaults.d.ts +208 -0
- package/types/src/contract/define.d.ts +376 -0
- package/types/src/contract/file.d.ts +44 -0
- package/types/src/contract/group.d.ts +274 -0
- package/types/src/contract/handled.d.ts +18 -0
- package/types/src/contract/index.d.ts +19 -0
- package/types/src/contract/kit.d.ts +211 -0
- package/types/src/contract/platform.d.ts +9 -0
- package/types/src/contract/problems.d.ts +14 -0
- package/types/src/contract/redirect.d.ts +25 -0
- package/types/src/contract/schema-from-shape.d.ts +10 -0
- package/types/src/contract/schema-shape.d.ts +5 -0
- package/types/src/contract/snapshot-source.d.ts +15 -0
- package/types/src/contract/token.d.ts +58 -0
- package/types/src/contract/vocabulary.d.ts +32 -0
- package/types/src/contract/webhook-binding.d.ts +24 -0
- package/types/src/contract/webhook-scheme.d.ts +23 -0
- package/types/src/dev/changes.d.ts +7 -0
- package/types/src/dev/contract-digest.d.ts +1 -0
- package/types/src/dev/index.d.ts +2 -0
- package/types/src/dev/launcher.d.ts +32 -0
- package/types/src/dev/output.d.ts +28 -0
- package/types/src/dev/wire.d.ts +126 -0
- package/types/src/disk-assets.d.ts +3 -0
- package/types/src/errors.d.ts +46 -0
- package/types/src/fetch.d.ts +7 -0
- package/types/src/files/archive.d.ts +26 -0
- package/types/src/files/clamscan.d.ts +32 -0
- package/types/src/files/handle.d.ts +106 -0
- package/types/src/files/job-names.d.ts +9 -0
- package/types/src/files/kinds.d.ts +101 -0
- package/types/src/files/scan.d.ts +78 -0
- package/types/src/files/sniff.d.ts +7 -0
- package/types/src/files/streams.d.ts +17 -0
- package/types/src/files/treatment.d.ts +18 -0
- package/types/src/from-row.d.ts +20 -0
- package/types/src/honeypot.d.ts +2 -0
- package/types/src/implement/anonymous.d.ts +28 -0
- package/types/src/implement/attachment.d.ts +14 -0
- package/types/src/implement/boot.d.ts +114 -0
- package/types/src/implement/bounded.d.ts +32 -0
- package/types/src/implement/browser-config.d.ts +19 -0
- package/types/src/implement/capability.d.ts +142 -0
- package/types/src/implement/challenge.d.ts +75 -0
- package/types/src/implement/client-address.d.ts +11 -0
- package/types/src/implement/connect.d.ts +67 -0
- package/types/src/implement/crashes.d.ts +31 -0
- package/types/src/implement/dispatch.d.ts +220 -0
- package/types/src/implement/egress.d.ts +14 -0
- package/types/src/implement/environment.d.ts +14 -0
- package/types/src/implement/failures.d.ts +39 -0
- package/types/src/implement/faults.d.ts +45 -0
- package/types/src/implement/headers.d.ts +7 -0
- package/types/src/implement/implement.d.ts +58 -0
- package/types/src/implement/index.d.ts +25 -0
- package/types/src/implement/invocation.d.ts +125 -0
- package/types/src/implement/membership.d.ts +201 -0
- package/types/src/implement/multipart.d.ts +36 -0
- package/types/src/implement/params.d.ts +7 -0
- package/types/src/implement/principal.d.ts +57 -0
- package/types/src/implement/project.d.ts +11 -0
- package/types/src/implement/public.d.ts +61 -0
- package/types/src/implement/serve.d.ts +25 -0
- package/types/src/implement/terminals.d.ts +33 -0
- package/types/src/implement/tokens.d.ts +49 -0
- package/types/src/implement/types.d.ts +243 -0
- package/types/src/implement/urls.d.ts +15 -0
- package/types/src/implement/verifiers.d.ts +19 -0
- package/types/src/implement/webhook.d.ts +55 -0
- package/types/src/implement/workload.d.ts +56 -0
- package/types/src/implement/xlsx.d.ts +6 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/jobs/exception.d.ts +40 -0
- package/types/src/jobs/mail.d.ts +67 -0
- package/types/src/jobs/report.d.ts +56 -0
- package/types/src/jobs/runner.d.ts +151 -0
- package/types/src/jobs/schedule.d.ts +17 -0
- package/types/src/jobs/scrub.d.ts +12 -0
- package/types/src/jobs/types.d.ts +98 -0
- package/types/src/react/caller.d.ts +30 -0
- package/types/src/react/errors.d.ts +40 -0
- package/types/src/react/hooks.d.ts +110 -0
- package/types/src/react/index.d.ts +5 -0
- package/types/src/react/provider.d.ts +58 -0
- package/types/src/react/store.d.ts +68 -0
- package/types/src/react/testing.d.ts +28 -0
- package/types/src/react/types.d.ts +33 -0
- package/types/src/rpc/builder.d.ts +52 -0
- package/types/src/rpc/contract.d.ts +23 -0
- package/types/src/rpc/dispatcher.d.ts +11 -0
- package/types/src/rpc/types.d.ts +62 -0
- package/types/src/service/define.d.ts +42 -0
- package/types/src/service/index.d.ts +3 -0
- package/types/src/service/serve.d.ts +28 -0
- package/types/src/standard-schema.d.ts +55 -0
- package/types/src/testing/caller.d.ts +95 -0
- package/types/src/testing/challenge.d.ts +11 -0
- package/types/src/testing/dev.d.ts +97 -0
- package/types/src/testing/endpoint-access.d.ts +107 -0
- package/types/src/testing/index.d.ts +12 -0
- package/types/src/testing/isolation.d.ts +3 -0
- package/types/src/testing/kit.d.ts +187 -0
- package/types/src/testing/membership.d.ts +132 -0
- package/types/src/testing/policy-isolation.d.ts +113 -0
- package/types/src/testing/preload.d.ts +1 -0
- package/types/src/testing/proxy.d.ts +40 -0
- package/types/src/testing/runner.d.ts +8 -0
- package/types/src/testing/sample-file.d.ts +5 -0
- package/types/src/testing/status.d.ts +4 -0
- package/types/src/undumpable.d.ts +5 -0
|
@@ -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
|
+
}
|
package/src/dev/wire.ts
ADDED
|
@@ -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)
|
package/src/files/archive.ts
CHANGED
|
@@ -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 ||
|
|
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 * * * *'
|
package/src/files/kinds.ts
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
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
|
}
|
package/src/files/streams.ts
CHANGED
|
@@ -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 =
|
|
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')
|
package/src/implement/boot.ts
CHANGED
|
@@ -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 {
|
|
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'
|
|
@@ -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`.
|
|
@@ -262,6 +275,23 @@ function readProxySecret(framework: Readonly<Record<string, string>>, required:
|
|
|
262
275
|
return proxySecretDigest(secret)
|
|
263
276
|
}
|
|
264
277
|
|
|
278
|
+
/** What an application's SQLite database (a Durable Object's, ADR-0065) cannot serve: raw SQL and a scanning file kind. */
|
|
279
|
+
function sqliteProblems(artifact: ContractArtifact, database: Database | undefined, problems: string[]): void {
|
|
280
|
+
if (database?.dialect !== 'sqlite') {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
for (const [site, reach] of Object.entries(artifact.declared ?? {})) {
|
|
284
|
+
if (reach.sql !== null) {
|
|
285
|
+
problems.push(`'${site}' declares sql: '${reach.sql}'; ctx.sql runs only on Postgres, and this application's database is SQLite (Cloudflare)`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const [name, kind] of Object.entries(artifact.fileKinds ?? {})) {
|
|
289
|
+
if (kind.scan === true) {
|
|
290
|
+
problems.push(`files '${name}' declares scan: true; no malware scanner runs on Cloudflare, so a scanning file kind is not served there`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
265
295
|
/** What this version cannot serve yet; a contract that declares it does not boot rather than answer nothing. */
|
|
266
296
|
function unservable(
|
|
267
297
|
artifact: ContractArtifact,
|
|
@@ -350,6 +380,8 @@ interface BoundDatabase {
|
|
|
350
380
|
readonly framework: FrameworkTables | null
|
|
351
381
|
/** The mini-ORM behind a staged-kit site's `ctx.db`; only a `scoped(tables)` layer has it. */
|
|
352
382
|
readonly data: DataAccess | null
|
|
383
|
+
/** Disconnects a `scoped(tables)` layer this boot bound; `null` for any other database. */
|
|
384
|
+
readonly release: (() => void) | null
|
|
353
385
|
}
|
|
354
386
|
|
|
355
387
|
/**
|
|
@@ -367,7 +399,7 @@ function contractDatabase(
|
|
|
367
399
|
}
|
|
368
400
|
if (!isScopedDatabase(database)) {
|
|
369
401
|
if ('locate' in database) {
|
|
370
|
-
return { database, framework: null, data: null }
|
|
402
|
+
return { database, framework: null, data: null, release: null }
|
|
371
403
|
}
|
|
372
404
|
problems.push('serve(contract, { database }) takes the scoped(tables) result of @fabrika/db itself, not a copy or a wrapper of it')
|
|
373
405
|
return null
|
|
@@ -377,8 +409,8 @@ function contractDatabase(
|
|
|
377
409
|
return null
|
|
378
410
|
}
|
|
379
411
|
try {
|
|
380
|
-
const locate =
|
|
381
|
-
return { database: { tables: database.tables, locate }, framework: frameworkTablesOf(database), data: dataAccessOf(database) }
|
|
412
|
+
const { locate, release } = bindScopedDatabaseProvisionally(database, { database: connection, capabilities, readOnly: requestIsReadOnly })
|
|
413
|
+
return { database: { tables: database.tables, locate }, framework: frameworkTablesOf(database), data: dataAccessOf(database), release }
|
|
382
414
|
} catch {
|
|
383
415
|
problems.push('the data layer was bound before boot; only the framework binds it, so remove @fabrika/db/framework from the application')
|
|
384
416
|
return null
|
|
@@ -651,11 +683,28 @@ function runtimePurposes(contract: AnyContract, artifact: ContractArtifact): Rea
|
|
|
651
683
|
}
|
|
652
684
|
|
|
653
685
|
export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
|
|
686
|
+
const binding: Binding = { release: null }
|
|
687
|
+
try {
|
|
688
|
+
return bootOnce(app, options, binding)
|
|
689
|
+
} catch (error) {
|
|
690
|
+
// A failed boot releases the data layer it bound, so a later boot of the same application binds it again.
|
|
691
|
+
binding.release?.()
|
|
692
|
+
throw error
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** What `bootOnce` bound: released when the boot fails, kept when it succeeds. */
|
|
697
|
+
interface Binding {
|
|
698
|
+
release: (() => void) | null
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function bootOnce(app: ContractImplementation, options: BootOptions, binding: Binding): ContractApp {
|
|
654
702
|
const problems: string[] = []
|
|
655
703
|
installEgressGuard()
|
|
656
704
|
// What a handler reaches through these is the platform's own request, which no site's egress covers.
|
|
657
705
|
const objects = options.files === undefined ? undefined : platformFacility(options.files)
|
|
658
706
|
const membershipRpc = options.membership === undefined ? undefined : platformFacility(options.membership)
|
|
707
|
+
const terminalRpc = options.terminals === undefined ? undefined : platformFacility(options.terminals)
|
|
659
708
|
const workloadTokens = options.workloadTokens === undefined ? undefined : platformFacility(options.workloadTokens)
|
|
660
709
|
const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
|
|
661
710
|
const artifact = compileContract(app.contract, app.database?.tables ?? {})
|
|
@@ -673,10 +722,12 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
673
722
|
const owned = new Set(Object.values(artifact.tables).flatMap((table) => table.scope.mode === 'dimension' ? [table.scope.dimension] : []))
|
|
674
723
|
const containers = containersOf(artifact)
|
|
675
724
|
unservable(artifact, options.assets, owned, containers, problems)
|
|
725
|
+
sqliteProblems(artifact, options.database, problems)
|
|
676
726
|
// This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
|
|
677
727
|
const issuer = createCapabilityIssuer()
|
|
678
728
|
const fileKinds = storedFileKinds(artifact, app.database, objects, problems)
|
|
679
729
|
const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
|
|
730
|
+
binding.release = bound?.release ?? null
|
|
680
731
|
const framework = bound?.framework ?? null
|
|
681
732
|
frameworkProblems(artifact, framework, problems)
|
|
682
733
|
const database = bound?.database ?? null
|
|
@@ -896,6 +947,11 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
896
947
|
if (site !== undefined && data === null) {
|
|
897
948
|
problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
898
949
|
}
|
|
950
|
+
if (artifact.terminal !== undefined && options.terminals === undefined) {
|
|
951
|
+
problems.push(
|
|
952
|
+
'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)',
|
|
953
|
+
)
|
|
954
|
+
}
|
|
899
955
|
const asking = [...endpoints.values()].find(asksIam)
|
|
900
956
|
if (asking !== undefined && options.membership === undefined) {
|
|
901
957
|
problems.push(
|
|
@@ -919,6 +975,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
919
975
|
containers,
|
|
920
976
|
assets: options.assets ?? null,
|
|
921
977
|
membership,
|
|
978
|
+
terminals: artifact.terminal === undefined ? null : (terminalRpc ?? null),
|
|
922
979
|
framework,
|
|
923
980
|
mint: issuer.mint,
|
|
924
981
|
capabilities: issuer.authority,
|
package/src/implement/bounded.ts
CHANGED
|
@@ -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):
|
|
13
|
-
return
|
|
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 {
|
|
@@ -269,14 +269,18 @@ export class Within<D extends string, A extends string> implements WithinCapabil
|
|
|
269
269
|
principal: string,
|
|
270
270
|
values: readonly string[] | 'all',
|
|
271
271
|
containers: readonly ContainerGrant[],
|
|
272
|
+
contained: readonly ContainerGrant[],
|
|
272
273
|
) {
|
|
273
274
|
if (token !== MINT) {
|
|
274
275
|
throw new CapabilityRefusedError('not_minted', 'a Within is minted by the framework only; declare the access form in fabrika.contract.ts')
|
|
275
276
|
}
|
|
276
277
|
const held = values === 'all' ? freeze([]) : containerGrantsOf(containers)
|
|
278
|
+
const inside = values === 'all' ? freeze([]) : containerGrantsOf(contained)
|
|
277
279
|
let count = values === 'all' ? 0 : values.length
|
|
278
|
-
for (
|
|
279
|
-
|
|
280
|
+
for (const group of [held, inside]) {
|
|
281
|
+
for (let index = 0; index < group.length; index++) {
|
|
282
|
+
count += group[index]?.values.length ?? 0
|
|
283
|
+
}
|
|
280
284
|
}
|
|
281
285
|
if (count > MAX_WITHIN_VALUES) {
|
|
282
286
|
throw new CapabilityLimitError(
|
|
@@ -292,6 +296,7 @@ export class Within<D extends string, A extends string> implements WithinCapabil
|
|
|
292
296
|
principal,
|
|
293
297
|
values: values === 'all' ? 'all' : copyOf(values),
|
|
294
298
|
containers: held,
|
|
299
|
+
contained: inside,
|
|
295
300
|
})
|
|
296
301
|
this.#frame = frame
|
|
297
302
|
this.#issuer = issuer
|
|
@@ -315,6 +320,11 @@ export class Within<D extends string, A extends string> implements WithinCapabil
|
|
|
315
320
|
return this.#grant.containers
|
|
316
321
|
}
|
|
317
322
|
|
|
323
|
+
/** The dimensions inside this one in which the caller holds the action: `note → [n1]` for a workspace list. */
|
|
324
|
+
get contained(): readonly ContainerGrant[] {
|
|
325
|
+
return this.#grant.contained
|
|
326
|
+
}
|
|
327
|
+
|
|
318
328
|
static {
|
|
319
329
|
readWithin = (value) =>
|
|
320
330
|
typeof value === 'object' && value !== null && #grant in value ? { grant: value.#grant, frame: value.#frame, issuer: value.#issuer } : undefined
|
|
@@ -434,6 +444,7 @@ export interface CapabilityMint {
|
|
|
434
444
|
principal: string,
|
|
435
445
|
values: readonly string[] | 'all',
|
|
436
446
|
containers?: readonly ContainerGrant[],
|
|
447
|
+
contained?: readonly ContainerGrant[],
|
|
437
448
|
): Within<D, A>
|
|
438
449
|
app<A extends string>(frame: RequestFrame, action: A, principal: string): AppCapability<A>
|
|
439
450
|
}
|
|
@@ -469,8 +480,9 @@ export function createCapabilityIssuer(): CapabilityIssuer {
|
|
|
469
480
|
principal: string,
|
|
470
481
|
values: readonly string[] | 'all',
|
|
471
482
|
containers: readonly ContainerGrant[] = [],
|
|
483
|
+
contained: readonly ContainerGrant[] = [],
|
|
472
484
|
): Within<D, A> {
|
|
473
|
-
return new Within(MINT, issuer, frame, dimension, action, principal, values, containers)
|
|
485
|
+
return new Within(MINT, issuer, frame, dimension, action, principal, values, containers, contained)
|
|
474
486
|
},
|
|
475
487
|
app<A extends string>(frame: RequestFrame, action: A, principal: string): AppCapability<A> {
|
|
476
488
|
return new AppCapability(MINT, issuer, frame, action, principal)
|