@fabrika/app 0.6.0 → 0.8.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.
- package/package.json +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +167 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// once described. `buildArtifact` reads only this shape, so a reader that produces it from source (B1) and
|
|
3
3
|
// `compileContract` normalize through the same code.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { CspSource, MailTemplate, RpcCaller } from '@fabrika/auth-core'
|
|
6
6
|
|
|
7
7
|
export interface ContractDeclaration<S> {
|
|
8
8
|
readonly app: string
|
|
@@ -10,11 +10,13 @@ export interface ContractDeclaration<S> {
|
|
|
10
10
|
readonly actions: Readonly<Record<string, string>>
|
|
11
11
|
readonly roles: Readonly<Record<string, RoleDeclaration>>
|
|
12
12
|
readonly mounts: Readonly<Record<string, MountDeclaration<S>>>
|
|
13
|
-
readonly files: Readonly<Record<string,
|
|
13
|
+
readonly files: Readonly<Record<string, FileKindDeclaration>>
|
|
14
14
|
readonly jobs: Readonly<Record<string, CronDeclaration>>
|
|
15
15
|
readonly exports: Readonly<Record<string, ExportDeclaration<S>>>
|
|
16
16
|
readonly imports: Readonly<Record<string, ImportDeclaration>>
|
|
17
17
|
readonly csp: CspDeclaration
|
|
18
|
+
/** The token purposes of `defineApp`'s options, by purpose; absent without any. */
|
|
19
|
+
readonly tokens?: Readonly<Record<string, TokenDeclaration<S>>>
|
|
18
20
|
/** A language IAM speaks; `buildArtifact` refuses another value. */
|
|
19
21
|
readonly locale?: string
|
|
20
22
|
readonly displayName?: string
|
|
@@ -30,7 +32,7 @@ export interface ContractDeclaration<S> {
|
|
|
30
32
|
* The keys of the object `defineContract`'s callback returns that are sections, not mounts. Every other key is a
|
|
31
33
|
* mount. A reader of source splits the object the same way.
|
|
32
34
|
*/
|
|
33
|
-
export const SURFACE_SECTIONS = ['files', 'jobs', 'exports', 'imports', 'csp'] as const
|
|
35
|
+
export const SURFACE_SECTIONS = ['files', 'jobs', 'exports', 'imports', 'csp', 'tokens'] as const
|
|
34
36
|
|
|
35
37
|
export type SurfaceSection = (typeof SURFACE_SECTIONS)[number]
|
|
36
38
|
|
|
@@ -39,7 +41,7 @@ export type SurfaceSection = (typeof SURFACE_SECTIONS)[number]
|
|
|
39
41
|
* records every other key of its options in `unknownOptions`, so an option can never replace what the builder set;
|
|
40
42
|
* `buildArtifact` refuses a non-empty `unknownOptions`. A reader of source fills it the same way.
|
|
41
43
|
*/
|
|
42
|
-
export type Builder = 'procedure' | 'rpc' | 'link' | 'spa' | '
|
|
44
|
+
export type Builder = 'procedure' | 'rpc' | 'link' | 'spa' | 'webhook' | 'fileKind' | 'cron' | 'export' | 'attachment'
|
|
43
45
|
|
|
44
46
|
export interface BuilderFields {
|
|
45
47
|
readonly options: readonly string[]
|
|
@@ -47,14 +49,13 @@ export interface BuilderFields {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export const BUILDER_FIELDS: Readonly<Record<Builder, BuilderFields>> = {
|
|
50
|
-
procedure: { options: ['input', 'output', 'access', '
|
|
52
|
+
procedure: { options: ['input', 'output', 'access', 'actions', 'admission', 'limits', 'http'], owned: ['~kind', 'type'] },
|
|
51
53
|
rpc: { options: ['callers'], owned: ['kind', 'prefix', 'procedures'] },
|
|
52
54
|
link: { options: ['spa', 'dimension', 'grants', 'issuedBy', 'maxDays', 'procedures'], owned: ['kind', 'prefix'] },
|
|
53
|
-
spa: { options: ['dir', 'access', '
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
cron: { options: ['actions', 'scope', 'timeout', 'retries'], owned: ['kind', 'schedule'] },
|
|
55
|
+
spa: { options: ['dir', 'access', 'browserConfig'], owned: ['kind', 'prefix'] },
|
|
56
|
+
webhook: { options: ['scheme', 'boundTo', 'actions', 'input', 'limits'], owned: ['kind', 'path'] },
|
|
57
|
+
fileKind: { options: ['types', 'maxBytes', 'scan'], owned: ['kind'] },
|
|
58
|
+
cron: { options: ['actions', 'limits', 'retries'], owned: ['kind', 'schedule'] },
|
|
58
59
|
export: { options: ['version', 'consumers', 'audience', 'procedures'], owned: ['kind'] },
|
|
59
60
|
attachment: { options: ['format', 'filename', 'row', 'maxBytes', 'maxRows'], owned: [] },
|
|
60
61
|
}
|
|
@@ -69,7 +70,7 @@ export function unknownOptions(options: object, builder: Builder): string[] {
|
|
|
69
70
|
* The off-road fields a site of the staged kit declares beside its builder's options. The kit copies them into
|
|
70
71
|
* `declared`; `buildArtifact` checks which of them the site may carry.
|
|
71
72
|
*/
|
|
72
|
-
export const DECLARED_FIELDS = ['sql', 'unscoped', 'connect', 'issues', '
|
|
73
|
+
export const DECLARED_FIELDS = ['sql', 'unscoped', 'connect', 'issues', 'mailer', 'kicks', 'binds', 'signs', 'egress'] as const
|
|
73
74
|
|
|
74
75
|
/** The keys of a kit site's `options` that are neither `builder`'s option fields nor a declared field. */
|
|
75
76
|
export function kitUnknownOptions(options: object, builder: Builder): string[] {
|
|
@@ -77,7 +78,7 @@ export function kitUnknownOptions(options: object, builder: Builder): string[] {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/**
|
|
80
|
-
* The reach a site of the staged kit declares, as written (`sql`, `unscoped`, `connect`, `issues`, `
|
|
81
|
+
* The reach a site of the staged kit declares, as written (`sql`, `unscoped`, `connect`, `issues`, `mailer`, `kicks`, `binds`, `signs`, `egress`): only the fields
|
|
81
82
|
* its options carry. Unchecked here: the static reader copies what the source holds, and `buildArtifact` parses it.
|
|
82
83
|
*/
|
|
83
84
|
export interface DeclaredDeclaration {
|
|
@@ -85,8 +86,11 @@ export interface DeclaredDeclaration {
|
|
|
85
86
|
readonly unscoped?: unknown
|
|
86
87
|
readonly connect?: unknown
|
|
87
88
|
readonly issues?: unknown
|
|
88
|
-
readonly then?: unknown
|
|
89
89
|
readonly mailer?: unknown
|
|
90
|
+
readonly kicks?: unknown
|
|
91
|
+
readonly binds?: unknown
|
|
92
|
+
readonly signs?: unknown
|
|
93
|
+
readonly egress?: unknown
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/** The declared fields of a kit site's options, each one only when the options carry it. */
|
|
@@ -96,11 +100,20 @@ export function declaredOf(options: DeclaredDeclaration): DeclaredDeclaration {
|
|
|
96
100
|
...(options.unscoped === undefined ? {} : { unscoped: options.unscoped }),
|
|
97
101
|
...(options.connect === undefined ? {} : { connect: options.connect }),
|
|
98
102
|
...(options.issues === undefined ? {} : { issues: options.issues }),
|
|
99
|
-
...(options.then === undefined ? {} : { then: options.then }),
|
|
100
103
|
...(options.mailer === undefined ? {} : { mailer: options.mailer }),
|
|
104
|
+
...(options.kicks === undefined ? {} : { kicks: options.kicks }),
|
|
105
|
+
...(options.binds === undefined ? {} : { binds: options.binds }),
|
|
106
|
+
...(options.signs === undefined ? {} : { signs: options.signs }),
|
|
107
|
+
...(options.egress === undefined ? {} : { egress: options.egress }),
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/** One token purpose as written: its claims schema and its longest lifetime; `buildArtifact` checks both. */
|
|
112
|
+
export interface TokenDeclaration<S> {
|
|
113
|
+
readonly claims: S
|
|
114
|
+
readonly maxDays: unknown
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
/** Option keys outside the builder's option fields; `buildArtifact` refuses any. */
|
|
105
118
|
interface OptionsRecord {
|
|
106
119
|
readonly unknownOptions?: readonly string[]
|
|
@@ -128,12 +141,27 @@ export type AccessDeclaration =
|
|
|
128
141
|
| { readonly action: string; readonly on: string; readonly from: string }
|
|
129
142
|
| { readonly action: string; readonly creates: string }
|
|
130
143
|
| { readonly action: string; readonly app: true }
|
|
144
|
+
| { readonly capabilities: Readonly<Record<string, CapabilityDeclaration>> }
|
|
145
|
+
/** Anyone calls it; its handler runs as the procedure's own workload, with its top-level `actions`. RPC only. */
|
|
146
|
+
| { readonly public: true }
|
|
147
|
+
|
|
148
|
+
/** One of several capabilities: an action in a dimension, listed `over` it or read from an input field. */
|
|
149
|
+
export type CapabilityDeclaration =
|
|
150
|
+
| { readonly action: string; readonly over: string }
|
|
151
|
+
| { readonly action: string; readonly in: string; readonly from: string }
|
|
152
|
+
| { readonly action: string; readonly on: string; readonly from: string }
|
|
131
153
|
|
|
132
154
|
/**
|
|
133
155
|
* An export procedure's access: an action in a dimension, never `member`, because the consumer's key holds exactly the
|
|
134
|
-
* actions,
|
|
156
|
+
* actions, never `app`, because a v1 export declares one dimension, and never several capabilities.
|
|
135
157
|
*/
|
|
136
|
-
export type ExportAccessDeclaration = Exclude<
|
|
158
|
+
export type ExportAccessDeclaration = Exclude<
|
|
159
|
+
AccessDeclaration,
|
|
160
|
+
| { readonly member: true }
|
|
161
|
+
| { readonly app: true }
|
|
162
|
+
| { readonly capabilities: Readonly<Record<string, CapabilityDeclaration>> }
|
|
163
|
+
| { readonly public: true }
|
|
164
|
+
>
|
|
137
165
|
|
|
138
166
|
export interface LinkAccessDeclaration {
|
|
139
167
|
readonly action: string
|
|
@@ -155,8 +183,14 @@ export interface ProcedureDeclaration<S, Access = AccessDeclaration> extends Opt
|
|
|
155
183
|
readonly input: S | null
|
|
156
184
|
readonly output: S
|
|
157
185
|
readonly access: Access
|
|
158
|
-
/**
|
|
159
|
-
readonly
|
|
186
|
+
/** A public procedure's workload actions, as written: all its handler holds. Required there, refused elsewhere. */
|
|
187
|
+
readonly actions?: unknown
|
|
188
|
+
/** `{ origins?, rate?, challenge? }`, as written; `buildArtifact` merges it into the defaults. */
|
|
189
|
+
readonly admission?: unknown
|
|
190
|
+
/** `{ maxBytes?, timeoutMs? }`, as written; `buildArtifact` checks each against its ceiling. */
|
|
191
|
+
readonly limits?: unknown
|
|
192
|
+
/** `{ method?, query?, body?, cache? }`, as written; `buildArtifact` checks the HTTP shape's rules. */
|
|
193
|
+
readonly http?: unknown
|
|
160
194
|
/** What a procedure of the staged kit declares off the paved road; a `defineContract` procedure has none. */
|
|
161
195
|
readonly declared?: unknown
|
|
162
196
|
/**
|
|
@@ -164,6 +198,13 @@ export interface ProcedureDeclaration<S, Access = AccessDeclaration> extends Opt
|
|
|
164
198
|
* `output` is the array of rows the handler returns.
|
|
165
199
|
*/
|
|
166
200
|
readonly attachment?: AttachmentDeclaration
|
|
201
|
+
/** Set by `describeContract` when the output is a `redirect()`: the procedure answers a redirect to `{ to }`. */
|
|
202
|
+
readonly redirect?: RedirectDeclaration
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The origins a `redirect()` output lists, as written; `buildArtifact` checks each. */
|
|
206
|
+
export interface RedirectDeclaration {
|
|
207
|
+
readonly origins: readonly string[]
|
|
167
208
|
}
|
|
168
209
|
|
|
169
210
|
/**
|
|
@@ -187,14 +228,16 @@ export type MountDeclaration<S> =
|
|
|
187
228
|
| LinkMountDeclaration<S>
|
|
188
229
|
| SpaMountDeclaration
|
|
189
230
|
| HealthMountDeclaration
|
|
190
|
-
| FormMountDeclaration<S>
|
|
191
231
|
| WebhookMountDeclaration<S>
|
|
192
232
|
|
|
193
233
|
export interface RpcMountDeclaration<S> extends OptionsRecord {
|
|
194
234
|
readonly kind: 'rpc'
|
|
195
235
|
readonly prefix: string
|
|
196
|
-
|
|
236
|
+
/** The callers of its credentialed procedures; none when every procedure is public. */
|
|
237
|
+
readonly callers: readonly RpcCaller[]
|
|
197
238
|
readonly procedures: ProcedureTreeDeclaration<S>
|
|
239
|
+
/** The kit's `c.rpc` options, as written; `buildArtifact` takes `'backward'` and `'unchecked'`. */
|
|
240
|
+
readonly compatibility?: unknown
|
|
198
241
|
}
|
|
199
242
|
|
|
200
243
|
export interface LinkMountDeclaration<S> extends OptionsRecord {
|
|
@@ -214,7 +257,7 @@ export interface SpaMountDeclaration extends OptionsRecord {
|
|
|
214
257
|
readonly dir: string
|
|
215
258
|
readonly access: SpaAccessDeclaration
|
|
216
259
|
/** Application variables whose values the mount's pages read from `<prefix>/__fabrika-config.json`. */
|
|
217
|
-
readonly
|
|
260
|
+
readonly browserConfig?: readonly string[]
|
|
218
261
|
}
|
|
219
262
|
|
|
220
263
|
export interface HealthMountDeclaration {
|
|
@@ -222,57 +265,30 @@ export interface HealthMountDeclaration {
|
|
|
222
265
|
readonly path: string
|
|
223
266
|
}
|
|
224
267
|
|
|
225
|
-
export interface FormMountDeclaration<S> extends OptionsRecord {
|
|
226
|
-
readonly kind: 'form'
|
|
227
|
-
readonly path: string
|
|
228
|
-
readonly input: S
|
|
229
|
-
readonly appendTo: string
|
|
230
|
-
readonly maxBytes?: number
|
|
231
|
-
/**
|
|
232
|
-
* Submissions per minute from one client and accepted in all, and the bytes a form with files writes per hour;
|
|
233
|
-
* `FORM_LIMITS` and `FORM_FILE_LIMITS` hold the defaults.
|
|
234
|
-
*/
|
|
235
|
-
readonly rate?: { readonly perClient?: number; readonly perForm?: number; readonly bytesPerHour?: number }
|
|
236
|
-
/** An input field that must stay empty. */
|
|
237
|
-
readonly honeypot?: string
|
|
238
|
-
/**
|
|
239
|
-
* A challenge verified on the server; `secret` names the application variable that holds the provider's secret,
|
|
240
|
-
* `siteKey` the one that holds the widget's public site key.
|
|
241
|
-
*/
|
|
242
|
-
readonly challenge?: { readonly provider: ChallengeProvider; readonly secret: string; readonly siteKey?: string }
|
|
243
|
-
/** The cron job of the contract that each accepted submission kicks. */
|
|
244
|
-
readonly then?: string
|
|
245
|
-
/** `true` (the environment's listed origins) or `'*'` (any origin), as written; `buildArtifact` refuses anything else. */
|
|
246
|
-
readonly crossOrigin?: unknown
|
|
247
|
-
/** The file fields, by name: `{ collection, required? }` each, as written; `buildArtifact` checks them. */
|
|
248
|
-
readonly files?: unknown
|
|
249
|
-
}
|
|
250
|
-
|
|
251
268
|
export interface WebhookMountDeclaration<S> extends OptionsRecord {
|
|
252
269
|
readonly kind: 'webhook'
|
|
253
270
|
readonly path: string
|
|
254
|
-
|
|
271
|
+
/** A preset or a verifier shape, as written; `buildArtifact` checks it. */
|
|
272
|
+
readonly scheme: unknown
|
|
255
273
|
/** A dimension, or `app` for the whole application. */
|
|
256
274
|
readonly boundTo: string
|
|
257
275
|
readonly actions: readonly string[]
|
|
258
276
|
readonly input: S
|
|
259
|
-
|
|
277
|
+
/** `{ maxBytes?, timeoutMs? }`, as written; `buildArtifact` checks each against its ceiling. */
|
|
278
|
+
readonly limits?: unknown
|
|
260
279
|
/** What a webhook of the staged kit declares off the paved road. */
|
|
261
280
|
readonly declared?: unknown
|
|
262
281
|
}
|
|
263
282
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
readonly
|
|
270
|
-
readonly maxBytes: number
|
|
283
|
+
/**
|
|
284
|
+
* A file kind (`c.files({ types, maxBytes, scan? })`, no `owner`): a class of stored files a table's file column holds.
|
|
285
|
+
* It has no owner, no actions and no endpoint.
|
|
286
|
+
*/
|
|
287
|
+
export interface FileKindDeclaration extends OptionsRecord {
|
|
288
|
+
readonly kind: 'file-kind'
|
|
271
289
|
readonly types: readonly string[]
|
|
272
|
-
readonly
|
|
273
|
-
/**
|
|
274
|
-
readonly link?: boolean | readonly string[]
|
|
275
|
-
/** Every upload waits `quarantined` until the scan pass gives it a clean verdict. */
|
|
290
|
+
readonly maxBytes: number
|
|
291
|
+
/** Every file waits `quarantined` until the scan gives it a clean verdict; a byte consumer answers 409 until then. */
|
|
276
292
|
readonly scan?: boolean
|
|
277
293
|
}
|
|
278
294
|
|
|
@@ -280,9 +296,8 @@ export interface CronDeclaration extends OptionsRecord {
|
|
|
280
296
|
readonly kind: 'cron'
|
|
281
297
|
readonly schedule: string
|
|
282
298
|
readonly actions: readonly string[]
|
|
283
|
-
/**
|
|
284
|
-
readonly
|
|
285
|
-
readonly timeout?: string
|
|
299
|
+
/** `{ timeoutMs? }`, as written; `buildArtifact` checks it against its ceiling. */
|
|
300
|
+
readonly limits?: unknown
|
|
286
301
|
readonly retries?: number
|
|
287
302
|
/** What a job of the staged kit declares off the paved road. */
|
|
288
303
|
readonly declared?: unknown
|
package/src/contract/defaults.ts
CHANGED
|
@@ -2,77 +2,165 @@
|
|
|
2
2
|
// source reaches them only through `buildArtifact`, so both producers emit the same artifact.
|
|
3
3
|
|
|
4
4
|
import type { BodyAdmission, ChallengeProvider } from '@fabrika/auth-core'
|
|
5
|
+
import { WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
|
|
5
6
|
|
|
6
7
|
export const CONTRACT_DEFAULTS = {
|
|
7
8
|
/** A failed job run is not retried unless the job asks for it: a retry repeats work. */
|
|
8
9
|
jobRetries: 0,
|
|
9
|
-
formMaxBytes: 16 * 1024,
|
|
10
|
-
webhookMaxBytes: 1024 * 1024,
|
|
11
|
-
fileMaxPerOwner: null,
|
|
12
|
-
fileLink: false,
|
|
13
10
|
importTimeoutMs: null,
|
|
14
11
|
} as const
|
|
15
12
|
|
|
16
13
|
/**
|
|
17
|
-
* A
|
|
18
|
-
*
|
|
19
|
-
* `maxPerMinute` each.
|
|
14
|
+
* A procedure's bounds: its body (`maxBytes`, without files) and its handler's time (`timeoutMs`, counted after the last
|
|
15
|
+
* body byte), each with its default and its ceiling, and the most calls a bucket admits per minute.
|
|
20
16
|
*/
|
|
21
|
-
export const
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
export const PROCEDURE_LIMITS = {
|
|
18
|
+
maxBytes: 64 * 1024,
|
|
19
|
+
maxMaxBytes: 1024 * 1024,
|
|
20
|
+
timeoutMs: 10_000,
|
|
21
|
+
maxTimeoutMs: 60_000,
|
|
24
22
|
maxPerMinute: 10_000,
|
|
23
|
+
maxBytesPerHour: 64 * 1024 * 1024 * 1024,
|
|
24
|
+
/** The body of a procedure with file fields: its default adds each field's files at its kind's `maxBytes`. */
|
|
25
|
+
maxFileBodyBytes: 48 * 1024 * 1024,
|
|
26
|
+
/** The files one request carries, at most: each field's `.max(n)` together. */
|
|
27
|
+
maxFiles: 20,
|
|
28
|
+
/** The headers of one part of a multipart body, at most. */
|
|
29
|
+
maxPartHeaderBytes: 8 * 1024,
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A procedure with file fields is `multipart/form-data`: its JSON input travels in the first part, named `input`, and
|
|
34
|
+
* each file in a part named after its field. A field of several files sends one part per file, under its name.
|
|
35
|
+
*/
|
|
36
|
+
export const FILE_INPUT_PART = 'input'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A webhook's bounds: a provider's body, by default the most a procedure without files takes, since providers send
|
|
40
|
+
* whole objects; up to what a procedure with file fields takes, since an inbound e-mail carries its attachments in the
|
|
41
|
+
* body for the handler to `put`; and its handler's time, a procedure's.
|
|
42
|
+
*/
|
|
43
|
+
export const WEBHOOK_LIMITS = {
|
|
44
|
+
maxBytes: PROCEDURE_LIMITS.maxMaxBytes,
|
|
45
|
+
maxMaxBytes: PROCEDURE_LIMITS.maxFileBodyBytes,
|
|
46
|
+
timeoutMs: PROCEDURE_LIMITS.timeoutMs,
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
/** A job attempt's time limit (`limits.timeoutMs`) and its ceiling, which keeps a lease and a timer within a day. */
|
|
50
|
+
export const JOB_LIMITS = {
|
|
51
|
+
timeoutMs: 15 * 60_000,
|
|
52
|
+
maxTimeoutMs: 24 * 60 * 60_000,
|
|
25
53
|
} as const
|
|
26
54
|
|
|
27
|
-
/**
|
|
28
|
-
export const
|
|
55
|
+
/** A public mutation's buckets per minute unless it declares its own: per client and for the endpoint. */
|
|
56
|
+
export const PUBLIC_MUTATION_RATE = { perClient: 10, perEndpoint: 100 } as const
|
|
57
|
+
|
|
58
|
+
/** What a widget's `action` may be: at most 32 letters, digits, `_` and `-`, what Turnstile admits, and a path segment. */
|
|
59
|
+
export const CHALLENGE_ACTION = /^[A-Za-z0-9_-]{1,32}$/
|
|
29
60
|
|
|
30
61
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
62
|
+
* The challenge widget's `action` of a public procedure that declares none (`challenge.action`): its endpoint id with
|
|
63
|
+
* `_` for `.`.
|
|
33
64
|
*/
|
|
65
|
+
export function challengeAction(endpoint: string): string {
|
|
66
|
+
return endpoint.replaceAll('.', '_')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The input field that carries a challenge token; a procedure with a challenge declares it as a string. */
|
|
70
|
+
export const CHALLENGE_FIELD = 'challengeToken'
|
|
71
|
+
|
|
72
|
+
/** A procedure with file fields reads `multipart/form-data`; the proxy admits that type for its row. */
|
|
34
73
|
export { FORM_MULTIPART_TYPE } from '@fabrika/auth-core'
|
|
35
|
-
export const FORM_INPUT_PART = 'input'
|
|
36
74
|
|
|
37
75
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `timeoutMs`; a part's headers hold at most `maxPartHeaderBytes`.
|
|
76
|
+
* Each challenge provider's fixed browser sources: the origins of its widget's script and frame, and `blob:` for workers
|
|
77
|
+
* when the widget starts its own. ALTCHA's widget is bundled with the page and solves in workers built from `Blob`s.
|
|
41
78
|
*/
|
|
42
|
-
export const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
79
|
+
export const CHALLENGE_ORIGINS: Readonly<
|
|
80
|
+
Record<ChallengeProvider, { readonly scriptSrc: readonly string[]; readonly frameSrc: readonly string[]; readonly workerSrc: readonly string[] }>
|
|
81
|
+
> = {
|
|
82
|
+
turnstile: { scriptSrc: ['https://challenges.cloudflare.com'], frameSrc: ['https://challenges.cloudflare.com'], workerSrc: [] },
|
|
83
|
+
altcha: { scriptSrc: [], frameSrc: [], workerSrc: [WORKER_BLOB_SOURCE] },
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An ALTCHA challenge: PBKDF2/SHA-256 attempts of `cost` iterations each, counted up to a number drawn per challenge
|
|
88
|
+
* between `minCounter` and `maxCounter`, so a browser makes that many attempts, about half of `maxCounter` on average.
|
|
89
|
+
* The draw starts at 1: a solver that knew a higher floor would skip the attempts below it, which the widget makes.
|
|
90
|
+
* `cost` and `maxAgeMs` are the contract's (`challenge.cost`, `challenge.maxAgeMs`), each with its default and its
|
|
91
|
+
* bounds, and together they must leave a slow phone time to solve (`altchaSolvable`). The HMAC key is at least
|
|
92
|
+
* `minKeyLength` characters: every issued challenge publishes a signature a short key could be guessed from. A solved
|
|
93
|
+
* payload is base64 JSON of the challenge and its solution, at most `maxTokenLength` characters.
|
|
94
|
+
*/
|
|
95
|
+
export const ALTCHA_LIMITS = {
|
|
96
|
+
algorithm: 'PBKDF2/SHA-256',
|
|
97
|
+
cost: 5000,
|
|
98
|
+
minCost: 1000,
|
|
99
|
+
maxCost: 18_000,
|
|
100
|
+
minCounter: 1,
|
|
101
|
+
maxCounter: 10_000,
|
|
102
|
+
maxAgeMs: 10 * 60_000,
|
|
103
|
+
minMaxAgeMs: 60_000,
|
|
104
|
+
maxMaxAgeMs: 60 * 60_000,
|
|
105
|
+
maxTokenLength: 4096,
|
|
106
|
+
minKeyLength: 32,
|
|
107
|
+
/** What a slow phone's widget runs over its workers, PBKDF2 iterations a second: the rate the worst case is sized by. */
|
|
108
|
+
slowIterationsPerSecond: 2_000_000,
|
|
109
|
+
/** How long the widget tries before it gives up. */
|
|
110
|
+
solveTimeoutMs: 90_000,
|
|
55
111
|
} as const
|
|
56
112
|
|
|
57
|
-
/**
|
|
58
|
-
export
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
113
|
+
/** How long a slow phone takes for the worst draw of a challenge of `cost`, in ms. */
|
|
114
|
+
export function altchaWorstSolveMs(cost: number): number {
|
|
115
|
+
return Math.ceil((cost * ALTCHA_LIMITS.maxCounter * 1000) / ALTCHA_LIMITS.slowIterationsPerSecond)
|
|
116
|
+
}
|
|
62
117
|
|
|
63
|
-
/**
|
|
64
|
-
export
|
|
118
|
+
/** Whether a slow phone solves the worst draw before the widget gives up and within half the challenge's lifetime. */
|
|
119
|
+
export function altchaSolvable(cost: number, maxAgeMs: number): boolean {
|
|
120
|
+
return altchaWorstSolveMs(cost) <= Math.min(ALTCHA_LIMITS.solveTimeoutMs, maxAgeMs / 2)
|
|
121
|
+
}
|
|
65
122
|
|
|
66
123
|
/**
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
124
|
+
* The `action` a procedure's challenge expects back: the declared one, else the endpoint id with `_` for `.`. The build
|
|
125
|
+
* and the typed client both read it here, so the client's challenge path is the artifact's.
|
|
126
|
+
*/
|
|
127
|
+
export function declaredChallengeAction(endpoint: string, declared: unknown): string {
|
|
128
|
+
return typeof declared === 'string' ? declared : challengeAction(endpoint)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Where an ALTCHA challenge is issued: one path per procedure, under its action. */
|
|
132
|
+
export const CHALLENGE_PREFIX = '/__fabrika/challenge'
|
|
133
|
+
|
|
134
|
+
export function challengePath(action: string): string {
|
|
135
|
+
return `${CHALLENGE_PREFIX}/${action}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The endpoint id of the row that issues the challenge of public procedure `endpoint`. */
|
|
139
|
+
export function challengeRowId(endpoint: string): string {
|
|
140
|
+
return `${endpoint}.challenge`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The buckets per minute of the endpoint that issues a challenge: a visitor fetches one per form shown and one more per
|
|
145
|
+
* expiry, and every issue costs the server one attempt's work. Each is at least this, and at least twice the
|
|
146
|
+
* procedure's own bucket, so the issuer never admits fewer submissions than the procedure declares. A bucket the
|
|
147
|
+
* procedure turns off (`null`) is this one on the issuer: the procedure's handler may be cheap, the issue never is.
|
|
148
|
+
*/
|
|
149
|
+
export const CHALLENGE_ISSUE_RATE = { perClient: 30, perEndpoint: 600 } as const
|
|
150
|
+
|
|
151
|
+
/** The issuer's bucket for the procedure's `own`, within the ceiling of every bucket. */
|
|
152
|
+
export function challengeIssueBucket(own: number | null, floor: number): number {
|
|
153
|
+
return Math.min(PROCEDURE_LIMITS.maxPerMinute, Math.max(floor, 2 * (own ?? 0)))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The bounds of a file kind: the largest file a `scan: true` kind admits (the scanner reads at most this much of one
|
|
158
|
+
* file, so a larger one could never be given a clean verdict).
|
|
69
159
|
*/
|
|
70
160
|
export const FILE_LIMITS = {
|
|
71
161
|
maxScannedBytes: 100 * 1024 * 1024,
|
|
72
|
-
/** Sniffable types a `scan: true`
|
|
162
|
+
/** Sniffable types a `scan: true` kind refuses: archives other than ZIP, whose contents the framework does not check. */
|
|
73
163
|
unscannedTypes: ['application/gzip'] as readonly string[],
|
|
74
|
-
listPageDefault: 50,
|
|
75
|
-
listPageMax: 100,
|
|
76
164
|
} as const
|
|
77
165
|
|
|
78
166
|
/**
|
|
@@ -116,15 +204,6 @@ export const LINK_API_SEGMENT = 'api'
|
|
|
116
204
|
/** The path segment a webhook appends for the binding a delivery addresses. */
|
|
117
205
|
export const WEBHOOK_BINDING_PARAMETER = 'bindingId'
|
|
118
206
|
|
|
119
|
-
/** Generated webhook binding endpoints (`webhooks.<name>.bind`, `.unbind`) live under this prefix, for signed-in people. */
|
|
120
|
-
export const WEBHOOKS_PREFIX = '/api'
|
|
121
|
-
|
|
122
|
-
/** The input field of a binding endpoint that names the scope value the binding reaches. */
|
|
123
|
-
export const WEBHOOK_SCOPE_FIELD = 'scope'
|
|
124
|
-
|
|
125
|
-
/** A GitHub account a binding accepts: an App installation or a repository, by numeric id. */
|
|
126
|
-
export const WEBHOOK_ACCOUNT_PATTERN = '^(?:installation|repository):[1-9][0-9]{0,19}$'
|
|
127
|
-
|
|
128
207
|
/**
|
|
129
208
|
* The share SPA's bootstrap, served by the framework under the link prefix and loaded before any other script: it
|
|
130
209
|
* takes the key out of the URL fragment and keeps it in a closure behind `SHARE_KEY_GLOBAL`.
|
|
@@ -168,6 +247,12 @@ export const GENERATED_LINK = 'fabrika:link'
|
|
|
168
247
|
*/
|
|
169
248
|
export const GENERATED_ROW_PREFIX = 'fabrika:row:'
|
|
170
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The prefix of the value a generated endpoint access case sends in a file field, followed by the field's file kind:
|
|
252
|
+
* `fabrika:file:attachments`. The endpoint access kit sends a small file of the kind's first type it can write in its place.
|
|
253
|
+
*/
|
|
254
|
+
export const GENERATED_FILE_PREFIX = 'fabrika:file:'
|
|
255
|
+
|
|
171
256
|
export const JSON_ADMISSION: BodyAdmission = { contentTypes: ['application/json'], maxBytes: null }
|
|
172
257
|
|
|
173
258
|
/** `/api` + `notes.list` → `/api/notes.list`; a root prefix does not double the `/`. */
|
|
@@ -176,13 +261,10 @@ export function joinPath(prefix: string, rest: string): string {
|
|
|
176
261
|
}
|
|
177
262
|
|
|
178
263
|
/**
|
|
179
|
-
* The
|
|
180
|
-
*
|
|
264
|
+
* The path of an RPC mount's procedure: its own `http.path` when it declares one (a public procedure only, which
|
|
265
|
+
* `buildArtifact` checks), else `<prefix>/<dotted.path>`. The typed client calls the path the artifact's row carries.
|
|
181
266
|
*/
|
|
182
|
-
export function
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
): { readonly upload: string; readonly download: string; readonly list: string; readonly remove: string } {
|
|
186
|
-
const at = joinPath(base, `files.${name}`)
|
|
187
|
-
return { upload: `${at}.upload`, download: at, list: `${at}.list`, remove: `${at}.remove` }
|
|
267
|
+
export function procedurePath(prefix: string, dotted: string, http: unknown): string {
|
|
268
|
+
const own: unknown = typeof http === 'object' && http !== null ? Reflect.get(http, 'path') : undefined
|
|
269
|
+
return typeof own === 'string' ? own : joinPath(prefix, dotted)
|
|
188
270
|
}
|