@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.
Files changed (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -6,7 +6,7 @@
6
6
  import { createContext, type ReactNode, useContext, useEffect, useState } from 'react'
7
7
  import { z } from 'zod/v4'
8
8
  import { type PageCaller, parsePageCaller } from '../client/caller'
9
- import { attachmentAt, type ClientNode, clientNodeOf, downloadFile } from '../client/contract'
9
+ import { attachmentAt, type ClientNode, clientNodeOf, type DownloadedFile, downloadFile } from '../client/contract'
10
10
  import { startErrorReporting } from '../client/error-reporting'
11
11
  import { RpcError, type RpcFetch } from '../client/transport'
12
12
  import { BROWSER_CONFIG_FILE, joinPath, PAGE_CALLER_FILE } from '../contract/defaults'
@@ -62,7 +62,7 @@ export interface Fabrika {
62
62
  /** The call of `procedure` on the provider's client of its mount. */
63
63
  target(procedure: unknown, hook: string): ResolvedProcedure
64
64
  /** The download of attachment `procedure` on the provider's client of its mount. */
65
- attachment(procedure: unknown, hook: string): (input: unknown) => Promise<File>
65
+ attachment(procedure: unknown, hook: string): (input: unknown) => Promise<DownloadedFile>
66
66
  }
67
67
 
68
68
  export interface ResolvedProcedure extends StoreTarget {
@@ -1,5 +1,10 @@
1
- // A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev`.
1
+ // A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev`; and the solver of an ALTCHA
2
+ // challenge, which needs no stand-in: the application issues and verifies it itself.
2
3
 
4
+ import { type Challenge, solveChallenge } from 'altcha-lib'
5
+ import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2'
6
+ import { z } from 'zod/v4'
7
+ import type { RpcFetch } from '../client/transport'
3
8
  import type { ChallengeFetch } from '../implement/challenge'
4
9
 
5
10
  /** The token Turnstile's always-passing test widget (site key `1x00000000000000000000AA`) hands out every time. */
@@ -8,3 +13,32 @@ export const TURNSTILE_TEST_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
8
13
  /** Turnstile's siteverify that passes every token: a fresh answer for the host and the form the check expects. */
9
14
  export const passingChallenge: ChallengeFetch = (_request, check) =>
10
15
  Promise.resolve(Response.json({ success: true, challenge_ts: new Date().toISOString(), hostname: check.hostname, action: check.action }))
16
+
17
+ // Read as loosely as the widget reads it: the verification is what holds a challenge to its shape.
18
+ const issuedChallenge = z.object({
19
+ parameters: z.object({ algorithm: z.string(), nonce: z.string(), salt: z.string(), cost: z.number(), keyLength: z.number(), keyPrefix: z.string() })
20
+ .catchall(z.unknown()),
21
+ signature: z.string().optional(),
22
+ })
23
+
24
+ /**
25
+ * Fetches a challenge from `url` through `fetch` and solves it as the widget does, answering the payload the page puts
26
+ * into `challengeToken`. The kit issues the cheapest challenge, so this takes milliseconds.
27
+ */
28
+ export async function solveChallengeAt(fetch: RpcFetch, url: string): Promise<string> {
29
+ const response = await fetch(url, { headers: { Accept: 'application/json' } })
30
+ if (response.status !== 200) {
31
+ throw new Error(`the challenge at ${url} answered ${response.status}: ${await response.text()}`)
32
+ }
33
+ const parsed = issuedChallenge.safeParse(await response.json())
34
+ if (!parsed.success) {
35
+ throw new Error(`the challenge at ${url} is no ALTCHA challenge`)
36
+ }
37
+ const { parameters, signature } = parsed.data
38
+ const challenge: Challenge = { parameters: { ...parameters }, ...(signature === undefined ? {} : { signature }) }
39
+ const solution = await solveChallenge({ challenge, deriveKey, timeout: 10_000 })
40
+ if (solution === null) {
41
+ throw new Error(`the challenge at ${url} was not solved in time`)
42
+ }
43
+ return btoa(JSON.stringify({ challenge, solution }))
44
+ }
@@ -106,9 +106,9 @@ export interface DevKit<C extends AnyContract> {
106
106
  const isPerson = (caller: TestCaller): caller is PersonTestCaller => caller.kind === 'human' && caller.email !== null
107
107
 
108
108
  /**
109
- * The test kit of `app` with its stand-in IAM, and what `fabrika app dev` shows of it. A form's challenge passes
109
+ * The test kit of `app` with its stand-in IAM, and what `fabrika app dev` shows of it. A challenge passes
110
110
  * (`passingChallenge`) unless `options.challengeFetch` answers it, and admits the test widget's token
111
- * (`TURNSTILE_TEST_TOKEN`) at every submission; any other token is single use, as deployed.
111
+ * (`TURNSTILE_TEST_TOKEN`) at every call; any other token is single use, as deployed.
112
112
  */
113
113
  export function devKit<C extends AnyContract>(app: ContractImplementation<C>, options: Omit<TestKitOptions, 'membership'> = {}): DevKit<C> {
114
114
  const { kit, standIn, callerOf } = bootTestKit(app, { challengeFetch: passingChallenge, ...options }, {
@@ -12,24 +12,32 @@ import {
12
12
  endpointDirectory,
13
13
  endpointMembers,
14
14
  type EndpointMethod,
15
- type FileCollectionArtifact,
15
+ type ProcedureChallenge,
16
16
  type SchemaShape,
17
17
  type Scope,
18
18
  } from '@fabrika/auth-core'
19
19
  import type { Tables } from '@fabrika/db'
20
20
  import { tablePolicies } from '@fabrika/db/tables'
21
- import type { IsolationWorlds, SampleValue, TestDatabase, WorldRow } from '@fabrika/db/testing'
21
+ import { type IsolationWorlds, type SampleValue, SEEDED_OBJECT_VERSION, type TestDatabase, type WorldRow } from '@fabrika/db/testing'
22
22
  import { MemoryFileObjects } from '@fabrika/platform/testing'
23
23
  import { z } from 'zod/v4'
24
24
  import type { RpcFetch } from '../client/transport'
25
25
  import { compileContract } from '../contract/compile'
26
- import { GENERATED_INVITATION, GENERATED_LINK, GENERATED_MEMBER, GENERATED_ROW_PREFIX } from '../contract/defaults'
26
+ import {
27
+ FILE_INPUT_PART,
28
+ GENERATED_FILE_PREFIX,
29
+ GENERATED_INVITATION,
30
+ GENERATED_LINK,
31
+ GENERATED_MEMBER,
32
+ GENERATED_ROW_PREFIX,
33
+ } from '../contract/defaults'
27
34
  import type { AnyContract } from '../contract/define'
28
- import { FILE_SCAN_JOB, type FileScanner, type ScanVerdict } from '../files/scan'
35
+ import type { FileScanner, ScanVerdict } from '../files/scan'
29
36
  import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
30
37
  import type { TestCaller, TestGrant } from './caller'
31
38
  import { standInIamKit } from './kit'
32
39
  import { scopeTypeOf } from './policy-isolation'
40
+ import { sampleFile } from './sample-file'
33
41
 
34
42
  export interface DeniedOutcome {
35
43
  readonly status: number
@@ -68,12 +76,6 @@ export interface EndpointAccessKit {
68
76
  * issuer the actions the link grants.
69
77
  */
70
78
  own(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
71
- /**
72
- * One file endpoint of `collection`, over a file seeded in each scope through the upload endpoint. `own` holds the
73
- * operation's action in one scope and addresses its own owner row or file, `other` holds it there and addresses
74
- * the other scope's, `reader` holds only the collection's read action and addresses its own.
75
- */
76
- file(collection: string, operation: FileOperation, caller: 'own' | 'other' | 'reader'): Promise<DeniedOutcome>
77
79
  /**
78
80
  * An `app` or a `creates` endpoint asked by a caller who holds its action in one value of a dimension only, never
79
81
  * globally: refused (403), by the proxy for `app` and by IAM for `creates`, since both need a global grant.
@@ -84,10 +86,21 @@ export interface EndpointAccessKit {
84
86
  * `answered` unless the answer is a 401, a 403 or a 5xx.
85
87
  */
86
88
  global(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>
89
+ /**
90
+ * One capability of an endpoint of several, refused while every other one passes: the caller holds each other one's
91
+ * action in a scope of its own and addresses its object or value there (or, for `over`, holds the action globally).
92
+ * For an `on` or `in` capability it also holds the named one's action in its own scope and addresses the other's
93
+ * object (404) or value (403); for `over` it holds the named action nowhere (403). `seed` is the named one's.
94
+ */
95
+ capability(endpoint: string, capability: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
96
+ /**
97
+ * A public procedure asked by anyone, with no credential, from a page of its own origin: `answered` unless the answer
98
+ * is a 401, a 403, a 5xx or a failed challenge. A procedure with a challenge is a 400 `challenge_failed`: the kit's
99
+ * challenge never passes.
100
+ */
101
+ anyone(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>
87
102
  }
88
103
 
89
- export type FileOperation = 'upload' | 'download' | 'remove'
90
-
91
104
  interface ActionEndpoint {
92
105
  readonly id: string
93
106
  /** The link mount of a link procedure; `null` for every other endpoint. */
@@ -151,35 +164,19 @@ export type Grant =
151
164
 
152
165
  const randomHex = (): string => crypto.randomUUID().replaceAll('-', '').slice(0, 8)
153
166
 
154
- const ascii = (text: string): number[] => [...text].map((char) => char.charCodeAt(0))
155
- const ZIP_HEAD = [0x50, 0x4b, 0x03, 0x04]
156
-
157
- /** The leading bytes `files/sniff.ts` accepts for each binary type it verifies; a text type needs none. */
158
- const SAMPLE_HEADS: Readonly<Record<string, readonly number[]>> = {
159
- 'image/png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
160
- 'image/jpeg': [0xff, 0xd8, 0xff, 0xe0],
161
- 'image/gif': ascii('GIF89a'),
162
- 'image/webp': [...ascii('RIFF'), 0, 0, 0, 0, ...ascii('WEBP')],
163
- 'application/pdf': ascii('%PDF-1.7\n'),
164
- 'application/gzip': [0x1f, 0x8b, 0x08],
165
- 'application/zip': ZIP_HEAD,
166
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_HEAD,
167
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_HEAD,
168
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_HEAD,
169
- }
170
- const TEXT_TYPES: ReadonlySet<string> = new Set(['text/plain', 'text/csv', 'text/markdown', 'application/json', 'image/svg+xml'])
167
+ const errorSchema = z.object({ error: z.object({ type: z.string() }) })
171
168
 
172
- /** A small file of the collection's first type the kit can write: the type's leading bytes, then a text marker. */
173
- function sampleFile(name: string, types: readonly string[]): { readonly type: string; readonly bytes: Uint8Array<ArrayBuffer> } {
174
- for (const type of types) {
175
- const head = SAMPLE_HEADS[type] ?? (TEXT_TYPES.has(type) ? [] : undefined)
176
- if (head !== undefined) return { type, bytes: new Uint8Array([...head, ...ascii(`fabrika endpoint access file ${randomHex()}`)]) }
177
- }
178
- throw new Error(`files.${name}: none of its types is one the endpoint access kit can write a file of`)
169
+ /** The file fields of a procedure's input: a file, an optional one, or an array of them, each sent as a part of its own. */
170
+ function fileFields(input: SchemaShape | null): ReadonlySet<string> {
171
+ const isFile = (shape: SchemaShape): boolean =>
172
+ shape.type === 'file' || ((shape.type === 'optional' || shape.type === 'array') && isFile(shape.type === 'array' ? shape.items : shape.inner))
173
+ return new Set(input?.type === 'object' ? Object.entries(input.fields).filter(([, shape]) => isFile(shape)).map(([name]) => name) : [])
179
174
  }
180
175
 
181
- const uploadedSchema = z.strictObject({ result: z.strictObject({ id: z.string() }) })
182
- const errorSchema = z.object({ error: z.object({ type: z.string() }) })
176
+ /** The file kind a generated file field's marker names, or `undefined` for any other value. */
177
+ function fileKindOf(value: unknown): string | undefined {
178
+ return typeof value === 'string' && value.startsWith(GENERATED_FILE_PREFIX) ? value.slice(GENERATED_FILE_PREFIX.length) : undefined
179
+ }
183
180
 
184
181
  /** The `type` of an error envelope, or `undefined` for any other body. */
185
182
  function errorType(text: string): string | undefined {
@@ -227,19 +224,20 @@ const cleanScanner: FileScanner = {
227
224
  }
228
225
 
229
226
  /**
230
- * The variables `boot` requires of `app` that a generated case never uses: each form challenge's secret and site key,
231
- * since no case submits a form, and each SPA mount's `public` variables, since no case reads a page's config. The
232
- * kit's challenge stand-in refuses every token, so no verification leaves the process.
227
+ * The variables `boot` requires of `app` that a generated case never uses: each challenge's secret and site key, since
228
+ * no case solves a challenge, and each SPA mount's `public` variables, since no case reads a page's config. The kit's
229
+ * challenge stand-in refuses every token, so no verification leaves the process.
233
230
  */
234
231
  function unusedVariables(app: ContractImplementation<AnyContract>): ApplicationEnvironment {
235
232
  const artifact = compileContract(app.contract, app.database?.tables ?? {})
236
- const names = Object.values(artifact.mounts).flatMap((mount): readonly string[] => {
237
- if (mount.kind === 'form' && mount.challenge !== undefined) {
238
- return mount.challenge.siteKey === undefined ? [mount.challenge.secret] : [mount.challenge.secret, mount.challenge.siteKey]
239
- }
240
- return mount.kind === 'spa' ? (mount.public ?? []) : []
241
- })
242
- return Object.fromEntries(names.map((name) => [name, 'unused']))
233
+ const challengeVariables = (challenge: ProcedureChallenge): readonly string[] =>
234
+ challenge.provider === 'altcha' || challenge.siteKey === undefined ? [challenge.secret] : [challenge.secret, challenge.siteKey]
235
+ const names = Object.values(artifact.mounts).flatMap((mount): readonly string[] => mount.kind === 'spa' ? (mount.public ?? []) : [])
236
+ const procedures = Object.values(artifact.procedures ?? {}).flatMap((procedure) =>
237
+ procedure.challenge === null ? [] : challengeVariables(procedure.challenge)
238
+ )
239
+ // Long enough for an ALTCHA key, which boot refuses under 32 characters.
240
+ return Object.fromEntries([...names, ...procedures].map((name) => [name, 'unused-by-every-generated-case-of-the-kit']))
243
241
  }
244
242
 
245
243
  /**
@@ -280,9 +278,10 @@ export const managerGrants = (
280
278
  */
281
279
  export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase): EndpointAccessKit {
282
280
  // The objects of the files the cases seed live in memory; their rows are the application's own tables.
281
+ const objects = new MemoryFileObjects()
283
282
  const { kit, standIn } = standInIamKit(app, {
284
283
  database: postgres.database,
285
- files: new MemoryFileObjects(),
284
+ files: objects,
286
285
  scanner: cleanScanner,
287
286
  env: unusedVariables(app),
288
287
  challengeFetch: () => Promise.resolve(Response.json({ success: false, 'error-codes': ['invalid-input-response'] })),
@@ -328,6 +327,30 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
328
327
  )
329
328
  }
330
329
 
330
+ /** An endpoint of several capabilities, as one `ActionEndpoint` per capability, by its name. */
331
+ const capabilitiesEndpointOf = (id: string): ReadonlyMap<string, ActionEndpoint> => {
332
+ const row = kit.artifact.endpoints.find((candidate) => candidate.id === id)
333
+ const requirement = row?.requirement
334
+ if (row === undefined || requirement?.kind !== 'capabilities') {
335
+ throw new Error(`${id} is not an endpoint of the contract with several capabilities`)
336
+ }
337
+ return new Map(
338
+ Object.entries(requirement.capabilities).map(([name, capability]) => [name, {
339
+ id: row.id,
340
+ link: null,
341
+ method: row.method,
342
+ path: row.path,
343
+ callers: row.callers,
344
+ action: capability.action,
345
+ access: capability.access,
346
+ input: row.input,
347
+ directory: null,
348
+ manages: null,
349
+ links: [],
350
+ }]),
351
+ )
352
+ }
353
+
331
354
  const globalEndpointOf = (id: string): ActionEndpoint => {
332
355
  const row = endpointOf(id)
333
356
  if (row.access.form !== 'app' && row.access.form !== 'creates') {
@@ -384,11 +407,58 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
384
407
  return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
385
408
  }
386
409
 
387
- const askAs = async (row: ActionEndpoint, fetch: RpcFetch, body: unknown): Promise<{ readonly status: number; readonly text: string }> => {
388
- if (row.method !== 'POST') {
389
- throw new Error(`${row.id} is not a procedure`)
410
+ // A seeded file column references an empty object, which a handler that serves or opens the file reads here.
411
+ let stocked = 0
412
+ const stockSeededObjects = (): void => {
413
+ const seeded = postgres.seededObjects()
414
+ for (const key of seeded.slice(stocked)) {
415
+ objects.objects.set(key, { bytes: new Uint8Array(0), contentType: 'application/octet-stream', version: SEEDED_OBJECT_VERSION })
390
416
  }
391
- const response = await fetch(row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
417
+ stocked = seeded.length
418
+ }
419
+
420
+ /** The request of a case: a GET's input in its URL, a file field's marker as a file of its kind in a multipart body. */
421
+ const requestOf = (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): readonly [string, RequestInit] => {
422
+ if (row.method === 'GET') {
423
+ const params = new URLSearchParams(Object.entries(body).map(([name, value]) => [name, value === null ? 'null' : String(value)]))
424
+ return [params.size === 0 ? row.path : `${row.path}?${params}`, { method: 'GET' }]
425
+ }
426
+ const fields = fileFields(row.input)
427
+ if (fields.size === 0) {
428
+ return [row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }]
429
+ }
430
+ const json: Record<string, unknown> = {}
431
+ const files: [string, File][] = []
432
+ for (const [name, value] of Object.entries(body)) {
433
+ if (!fields.has(name)) {
434
+ json[name] = value
435
+ continue
436
+ }
437
+ for (const item of Array.isArray(value) ? value : [value]) {
438
+ const kind = fileKindOf(item)
439
+ if (kind === undefined) throw new Error(`${row.id}: the file field ${name} holds ${JSON.stringify(item)}, not a generated file`)
440
+ files.push([name, generatedFile(kind)])
441
+ }
442
+ }
443
+ const form = new FormData()
444
+ form.append(FILE_INPUT_PART, JSON.stringify(json))
445
+ for (const [name, file] of files) form.append(name, file)
446
+ return [row.path, { method: 'POST', body: form }]
447
+ }
448
+
449
+ const generatedFile = (kind: string): File => {
450
+ const sample = sampleFile(kind, kit.artifact.fileKinds?.[kind]?.types ?? [])
451
+ return new File([sample.bytes], `generated-${kind}`, { type: sample.type })
452
+ }
453
+
454
+ const askAs = async (
455
+ row: ActionEndpoint,
456
+ fetch: RpcFetch,
457
+ body: Readonly<Record<string, unknown>>,
458
+ ): Promise<{ readonly status: number; readonly text: string }> => {
459
+ stockSeededObjects()
460
+ const [url, init] = requestOf(row, body)
461
+ const response = await fetch(url, init)
392
462
  const text = await response.text()
393
463
  // A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
394
464
  // the input it was given is no answer to another scope. Either is what the generator could not build.
@@ -405,39 +475,6 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
405
475
  return { status: response.status, text }
406
476
  }
407
477
 
408
- const collectionOf = (name: string): FileCollectionArtifact => {
409
- const collection = kit.artifact.files[name]
410
- if (collection === undefined) throw new Error(`${name} is not a file collection of the contract`)
411
- return collection
412
- }
413
-
414
- const fileRow = (name: string, operation: FileOperation): { readonly method: EndpointMethod; readonly path: string } => {
415
- const row = kit.artifact.endpoints.find((candidate) => candidate.id === `files.${name}.${operation}`)
416
- if (row === undefined) throw new Error(`files.${name}.${operation} is not an endpoint of the contract`)
417
- return { method: row.method, path: row.path }
418
- }
419
-
420
- /** One owner row in each scope, and one file of each, written through the upload endpoint as a person of that scope. */
421
- const seedFiles = async (name: string) => {
422
- const collection = collectionOf(name)
423
- const worlds = await postgres.seedWorlds(tables, collection.owner)
424
- const upload = fileRow(name, 'upload')
425
- const put = async (world: 'own' | 'other'): Promise<string> => {
426
- const caller = kit.testCaller([{ action: collection.write, scope: { type: worlds.dimension, value: worlds[world].scope } }])
427
- const file = sampleFile(name, collection.types)
428
- const response = await kit.browser(caller)(upload.path.replace(':owner', encodeURIComponent(worlds[world].key)), {
429
- method: 'POST',
430
- headers: { 'Content-Type': file.type },
431
- body: file.bytes,
432
- })
433
- if (response.status !== 200) throw new Error(`files.${name}.upload: seeding a file of the ${world} scope answered ${response.status}`)
434
- return uploadedSchema.parse(await response.json()).result.id
435
- }
436
- const seeded = { collection, worlds, own: await put('own'), other: await put('other') }
437
- if (collection.scan === true) await kit.runJob(FILE_SCAN_JOB)
438
- return seeded
439
- }
440
-
441
478
  /**
442
479
  * Two scopes of `dimension`: rows of the table that owns it when one does, with `seed`, otherwise two fresh scope
443
480
  * values. Each row `input` names in the object (`rows`) is seeded in each scope's row.
@@ -450,25 +487,10 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
450
487
  input: Readonly<Record<string, unknown>>,
451
488
  ): Promise<Scopes> => {
452
489
  const named = rowFields(input)
453
- const collection = Object.keys(kit.artifact.files).find((name) => dimension === `file:${name}`)
454
490
  const owner = ownerOf(dimension)
455
- if (named.length > 0 && (collection !== undefined || owner === undefined)) {
491
+ if (named.length > 0 && owner === undefined) {
456
492
  throw new Error(`no table owns ${dimension}, so the kit seeds no ${named.map((row) => row.table).join(', ')} row in one`)
457
493
  }
458
- if (collection !== undefined) {
459
- const seeded = await seedFiles(collection)
460
- const grant = (world: 'own' | 'other'): Grant[] => [{ action, scope: { type: seeded.worlds.dimension, value: seeded.worlds[world].scope } }]
461
- return {
462
- own: { target: seeded.own, grants: grant('own'), chain: seeded.worlds.own.chain, rows: {} },
463
- other: {
464
- target: seeded.other,
465
- grants: grant('other'),
466
- values: [...worldValues(seeded.worlds, 'other'), seeded.other],
467
- chain: seeded.worlds.other.chain,
468
- rows: {},
469
- },
470
- }
471
- }
472
494
  if (owner === undefined) {
473
495
  const own = freshValue(dimension, 'own', field)
474
496
  const other = freshValue(dimension, 'other', field)
@@ -773,43 +795,60 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
773
795
  )
774
796
  return { status }
775
797
  },
776
- async file(name, operation, caller) {
777
- if (operation === 'upload') {
778
- // An upload needs owners only: a seeded file would hold the owner's place, and a collection of
779
- // `maxPerOwner: 1` could never take the probe's.
780
- const collection = collectionOf(name)
781
- const worlds = await postgres.seedWorlds(tables, collection.owner)
782
- const grants = [{ action: caller === 'reader' ? collection.read : collection.write, scope: { type: worlds.dimension, value: worlds.own.scope } }]
783
- const file = sampleFile(name, collection.types)
784
- const path = fileRow(name, 'upload').path.replace(':owner', encodeURIComponent(worlds[caller === 'other' ? 'other' : 'own'].key))
785
- const fetch = kit.browser(kit.testCaller(grants))
786
- return { status: (await fetch(path, { method: 'POST', headers: { 'Content-Type': file.type }, body: file.bytes })).status }
787
- }
788
- const { collection, worlds, own, other } = await seedFiles(name)
789
- const operationAction = operation === 'download' ? collection.read : collection.delete
790
- const grants = [{ action: caller === 'reader' ? collection.read : operationAction, scope: { type: worlds.dimension, value: worlds.own.scope } }]
791
- const world = caller === 'other' ? 'other' : 'own'
792
- const fetch = kit.browser(kit.testCaller(grants))
793
- const row = fileRow(name, operation)
794
- const fileId = world === 'own' ? own : other
795
- if (operation === 'download') {
796
- return { status: (await fetch(row.path.replace(':fileId', encodeURIComponent(fileId)))).status }
797
- }
798
- const body = JSON.stringify({ id: fileId })
799
- return { status: (await fetch(row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })).status }
800
- },
801
798
  async scoped(endpoint, input) {
802
799
  const row = globalEndpointOf(endpoint)
803
800
  const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
804
801
  const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
805
802
  return { status }
806
803
  },
804
+ async anyone(endpoint, input) {
805
+ const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
806
+ if (row?.kind !== 'public') {
807
+ throw new Error(`${endpoint} is not a public procedure of the contract`)
808
+ }
809
+ const response = await kit.fetch(row.path, {
810
+ method: row.method,
811
+ headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
812
+ body: JSON.stringify(input),
813
+ })
814
+ const status = response.status
815
+ const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
816
+ return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
817
+ },
807
818
  async global(endpoint, input) {
808
819
  const row = globalEndpointOf(endpoint)
809
820
  const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
810
821
  // Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
811
822
  return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
812
823
  },
824
+ async capability(endpoint, capability, input, seed = {}) {
825
+ const capabilities = capabilitiesEndpointOf(endpoint)
826
+ const named = capabilities.get(capability)
827
+ if (named === undefined) {
828
+ throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
829
+ }
830
+ const grants: Grant[] = []
831
+ const global: { readonly action: string }[] = []
832
+ let asked = input
833
+ for (const [name, other] of capabilities) {
834
+ if (name === capability) continue
835
+ if (other.access.form === 'over') {
836
+ global.push({ action: other.action })
837
+ } else if (other.access.form === 'on' || other.access.form === 'in') {
838
+ const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
839
+ asked = addressing(asked, other.access.from, own)
840
+ grants.push(...own.grants)
841
+ }
842
+ }
843
+ const { access } = named
844
+ if (access.form === 'on' || access.form === 'in') {
845
+ const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
846
+ asked = addressing(asked, access.from, scopes.other)
847
+ grants.push(...scopes.own.grants)
848
+ }
849
+ const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
850
+ return { status }
851
+ },
813
852
  }
814
853
  // Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one.
815
854
  const drained = <A extends readonly unknown[], R>(method: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
@@ -823,8 +862,9 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
823
862
  denied: drained(accessKit.denied),
824
863
  leaked: drained(accessKit.leaked),
825
864
  own: drained(accessKit.own),
826
- file: drained(accessKit.file),
827
865
  scoped: drained(accessKit.scoped),
828
866
  global: drained(accessKit.global),
867
+ anyone: drained(accessKit.anyone),
868
+ capability: drained(accessKit.capability),
829
869
  })
830
870
  }