@fabrika/app 0.5.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -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 +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- 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 +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -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 +142 -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/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- 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
package/src/implement/bounded.ts
CHANGED
|
@@ -70,3 +70,26 @@ export async function readBoundedBytes(stream: ReadableStream<Uint8Array> | null
|
|
|
70
70
|
signal?.removeEventListener('abort', cancel)
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/** `body` passed on as it streams, failed once more than `maxBytes` went through; `exceeded` tells that failure apart. */
|
|
75
|
+
export function boundedStream(body: ReadableStream<Uint8Array>, maxBytes: number): {
|
|
76
|
+
readonly stream: ReadableStream<Uint8Array>
|
|
77
|
+
exceeded(): boolean
|
|
78
|
+
} {
|
|
79
|
+
let size = 0
|
|
80
|
+
let over = false
|
|
81
|
+
const stream = body.pipeThrough(
|
|
82
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
83
|
+
transform(chunk, controller) {
|
|
84
|
+
size += chunk.byteLength
|
|
85
|
+
if (size > maxBytes) {
|
|
86
|
+
over = true
|
|
87
|
+
controller.error(new Error(`the body is longer than ${maxBytes} bytes`))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
controller.enqueue(chunk)
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
return { stream, exceeded: () => over }
|
|
95
|
+
}
|
|
@@ -11,7 +11,7 @@ export interface BrowserConfig {
|
|
|
11
11
|
/** `FABRIKA_ENVIRONMENT`, which the page's error reports carry, or `null` when unset. */
|
|
12
12
|
readonly environment: string | null
|
|
13
13
|
/**
|
|
14
|
-
* Every
|
|
14
|
+
* Every public procedure challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
|
|
15
15
|
* there is none, so the file of an application without them holds the three Operations values only.
|
|
16
16
|
*/
|
|
17
17
|
readonly public?: Readonly<Record<string, string>>
|
|
@@ -33,11 +33,11 @@ export function browserConfigs(
|
|
|
33
33
|
return typeof value === 'string' && value.trim() !== '' ? value : null
|
|
34
34
|
}
|
|
35
35
|
const siteKeys: Record<string, string> = {}
|
|
36
|
-
for (const [
|
|
37
|
-
const challenge =
|
|
36
|
+
for (const [id, procedure] of Object.entries(artifact.procedures ?? {})) {
|
|
37
|
+
const challenge = procedure.challenge
|
|
38
38
|
if (challenge?.siteKey === undefined) continue
|
|
39
39
|
const value = valueOf(challenge.siteKey)
|
|
40
|
-
if (value === null) problems.push(`
|
|
40
|
+
if (value === null) problems.push(`procedure '${id}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
|
|
41
41
|
else siteKeys[challenge.siteKey] = value
|
|
42
42
|
}
|
|
43
43
|
const configs = new Map<string, BrowserConfig>()
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
MAX_WITHIN_VALUES,
|
|
16
16
|
type Ref as RefCapability,
|
|
17
17
|
type RefGrant,
|
|
18
|
+
type RowGrant,
|
|
19
|
+
type RowRef as RowRefCapability,
|
|
18
20
|
type Within as WithinCapability,
|
|
19
21
|
type WithinGrant,
|
|
20
22
|
} from '@fabrika/db/capability'
|
|
@@ -28,8 +30,13 @@ export const CAPABILITY_REFUSALS = {
|
|
|
28
30
|
wrong_target: "an IAM request was not passed this request's ctx.target",
|
|
29
31
|
undeclared_action: "the job's declaration or its token does not carry the action",
|
|
30
32
|
undeclared_dimension: 'the job named a dimension the contract does not declare',
|
|
33
|
+
undeclared_kick: 'the site kicked a job it does not declare in kicks',
|
|
34
|
+
undeclared_binding: 'the site named a webhook it does not declare in binds',
|
|
35
|
+
undeclared_signs: 'the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows',
|
|
36
|
+
undeclared_egress: 'the site called an origin it does not declare in egress',
|
|
37
|
+
wrong_binding: 'a webhook binding was handed a capability of another kind or dimension than the webhook is bound to',
|
|
31
38
|
missing_id: 'the job named an object without an id',
|
|
32
|
-
wrong_capability: '
|
|
39
|
+
wrong_capability: 'an operation was handed a capability of another dimension or action than it needs',
|
|
33
40
|
} as const
|
|
34
41
|
|
|
35
42
|
export type CapabilityRefusal = keyof typeof CAPABILITY_REFUSALS
|
|
@@ -173,6 +180,7 @@ interface Bound {
|
|
|
173
180
|
let readRef: (value: unknown) => Bound | undefined = () => undefined
|
|
174
181
|
let readWithin: (value: unknown) => Bound | undefined = () => undefined
|
|
175
182
|
let readApp: (value: unknown) => Bound | undefined = () => undefined
|
|
183
|
+
let readRow: (value: unknown) => Bound | undefined = () => undefined
|
|
176
184
|
|
|
177
185
|
/** One authorized object for one action, in one request. `chain` holds the containers the object lies in. */
|
|
178
186
|
export class Ref<D extends string, A extends string> implements RefCapability<D, A> {
|
|
@@ -344,10 +352,70 @@ export class AppCapability<A extends string> implements AppCapabilityType<A> {
|
|
|
344
352
|
}
|
|
345
353
|
}
|
|
346
354
|
|
|
355
|
+
interface RowFacts {
|
|
356
|
+
readonly table: string
|
|
357
|
+
readonly action: string
|
|
358
|
+
readonly principal: string
|
|
359
|
+
readonly id: string
|
|
360
|
+
readonly chain: Chain
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* One row a claim leased, for one action, in one job attempt: `row.ref`, which `tx.db.on(row.ref)` finishes the row
|
|
365
|
+
* through. `chain` holds the row's containment columns as it was claimed.
|
|
366
|
+
*/
|
|
367
|
+
export class RowRef<T extends string, A extends string> implements RowRefCapability<T, A> {
|
|
368
|
+
declare readonly '~capability': { readonly kind: 'row'; readonly table: T; readonly action: A }
|
|
369
|
+
readonly #facts: RowFacts
|
|
370
|
+
readonly #frame: RequestFrame
|
|
371
|
+
readonly #issuer: object
|
|
372
|
+
|
|
373
|
+
constructor(token: typeof MINT, issuer: object, frame: RequestFrame, grant: Omit<RowGrant, 'kind'>) {
|
|
374
|
+
if (token !== MINT) {
|
|
375
|
+
throw new CapabilityRefusedError('not_minted', 'a row capability is minted by the framework only: claim the row')
|
|
376
|
+
}
|
|
377
|
+
this.#facts = freeze({ table: grant.table, action: grant.action, principal: grant.principal, id: grant.id, chain: chainOf(grant.chain) })
|
|
378
|
+
this.#frame = frame
|
|
379
|
+
this.#issuer = issuer
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
get table(): string {
|
|
383
|
+
return this.#facts.table
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
get action(): string {
|
|
387
|
+
return this.#facts.action
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The row's key. */
|
|
391
|
+
get value(): string {
|
|
392
|
+
return this.#facts.id
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
static {
|
|
396
|
+
readRow = (value) => {
|
|
397
|
+
if (typeof value !== 'object' || value === null || !(#facts in value)) {
|
|
398
|
+
return undefined
|
|
399
|
+
}
|
|
400
|
+
const facts = value.#facts
|
|
401
|
+
const grant: RowGrant = freeze({
|
|
402
|
+
kind: 'row',
|
|
403
|
+
table: facts.table,
|
|
404
|
+
action: facts.action,
|
|
405
|
+
principal: facts.principal,
|
|
406
|
+
id: facts.id,
|
|
407
|
+
chain: mapOf(facts.chain),
|
|
408
|
+
})
|
|
409
|
+
return { grant, frame: value.#frame, issuer: value.#issuer }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
347
414
|
// Application code could otherwise redefine a getter that the framework or a handler reads.
|
|
348
415
|
freeze(Ref.prototype)
|
|
349
416
|
freeze(Within.prototype)
|
|
350
417
|
freeze(AppCapability.prototype)
|
|
418
|
+
freeze(RowRef.prototype)
|
|
351
419
|
|
|
352
420
|
/** Makes the capabilities one authority accepts; a runtime's is held by the `boot` call that made it alone. */
|
|
353
421
|
export interface CapabilityMint {
|
|
@@ -410,7 +478,7 @@ export function createCapabilityIssuer(): CapabilityIssuer {
|
|
|
410
478
|
})
|
|
411
479
|
const authority: CapabilityAuthority = freeze({
|
|
412
480
|
resolve(capability: unknown): CapabilityGrant {
|
|
413
|
-
const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability)
|
|
481
|
+
const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability) ?? readRow(capability)
|
|
414
482
|
if (bound === undefined || bound.issuer !== issuer) {
|
|
415
483
|
throw new CapabilityRefusedError('not_minted', 'not a capability the framework minted: pass the ctx.target or ctx.within of this request')
|
|
416
484
|
}
|
|
@@ -431,6 +499,14 @@ export function createCapabilityIssuer(): CapabilityIssuer {
|
|
|
431
499
|
return grant
|
|
432
500
|
},
|
|
433
501
|
|
|
502
|
+
row(grant: Omit<RowGrant, 'kind'>): RowRef<string, string> {
|
|
503
|
+
const frame = currentRequest.getStore()
|
|
504
|
+
if (frame === undefined || frame.ended) {
|
|
505
|
+
throw new CapabilityRefusedError('ended', 'a claimed row is minted only inside the job attempt that claims it')
|
|
506
|
+
}
|
|
507
|
+
return new RowRef(MINT, issuer, frame, grant)
|
|
508
|
+
},
|
|
509
|
+
|
|
434
510
|
fence(): LeaseFence | null {
|
|
435
511
|
return currentRequest.getStore()?.fence ?? null
|
|
436
512
|
},
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The client a per-client bucket counts. The buckets live in the application's database (`@fabrika/db`'s
|
|
2
|
+
// `Admissions`), so every instance shares them. Admission is not authorization: it only bounds how much one client
|
|
3
|
+
// calls a public procedure.
|
|
4
4
|
|
|
5
5
|
import { CLIENT_ADDRESS_HEADER } from '@fabrika/auth-core'
|
|
6
6
|
import { createHash } from 'node:crypto'
|
|
7
|
-
import { FORM_CHALLENGE_FIELD } from '../contract/defaults'
|
|
8
|
-
|
|
9
|
-
/** The bucket of a form's accepted submissions; a client's bucket is `client:<digest>`, so the two never meet. */
|
|
10
|
-
export const ACCEPTED_BUCKET = 'accepted'
|
|
11
7
|
|
|
12
8
|
/** Longest header value read; an IPv6 address with a zone id fits with room to spare. */
|
|
13
9
|
const MAX_ADDRESS_LENGTH = 64
|
|
@@ -87,34 +83,3 @@ export function clientKey(header: string | null): string {
|
|
|
87
83
|
export function clientBucket(request: Request): string {
|
|
88
84
|
return `client:${createHash('sha256').update(clientKey(request.headers.get(CLIENT_ADDRESS_HEADER))).digest('hex')}`
|
|
89
85
|
}
|
|
90
|
-
|
|
91
|
-
/** What the form declared beside its row: its honeypot field and whether a challenge token travels in the input. */
|
|
92
|
-
export interface SubmissionFields {
|
|
93
|
-
readonly honeypot: string | null
|
|
94
|
-
readonly challenge: boolean
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface Submission {
|
|
98
|
-
/** The honeypot was filled: the answer is the constant one, and nothing is written. */
|
|
99
|
-
readonly trapped: boolean
|
|
100
|
-
/** The challenge token, when the form declares a challenge. */
|
|
101
|
-
readonly token: string | null
|
|
102
|
-
/** The columns the inbox row gets: the input without the honeypot and the token. */
|
|
103
|
-
readonly row: Readonly<Record<string, unknown>>
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function submissionOf(input: Readonly<Record<string, unknown>>, fields: SubmissionFields): Submission {
|
|
107
|
-
const row: Record<string, unknown> = {}
|
|
108
|
-
for (const [name, value] of Object.entries(input)) {
|
|
109
|
-
if (name !== fields.honeypot && !(fields.challenge && name === FORM_CHALLENGE_FIELD)) {
|
|
110
|
-
row[name] = value
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
const trap = fields.honeypot === null ? undefined : input[fields.honeypot]
|
|
114
|
-
const token = fields.challenge ? input[FORM_CHALLENGE_FIELD] : undefined
|
|
115
|
-
return {
|
|
116
|
-
trapped: typeof trap === 'string' && trap !== '',
|
|
117
|
-
token: typeof token === 'string' ? token : null,
|
|
118
|
-
row,
|
|
119
|
-
}
|
|
120
|
-
}
|