@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.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -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 +387 -236
  10. package/skill/references/spa.md +5 -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 +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  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 +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. 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 {
@@ -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 FormChallenge,
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,19 @@ 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: FormChallenge): readonly string[] =>
234
+ 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
+ return Object.fromEntries([...names, ...procedures].map((name) => [name, 'unused']))
243
240
  }
244
241
 
245
242
  /**
@@ -280,9 +277,10 @@ export const managerGrants = (
280
277
  */
281
278
  export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase): EndpointAccessKit {
282
279
  // The objects of the files the cases seed live in memory; their rows are the application's own tables.
280
+ const objects = new MemoryFileObjects()
283
281
  const { kit, standIn } = standInIamKit(app, {
284
282
  database: postgres.database,
285
- files: new MemoryFileObjects(),
283
+ files: objects,
286
284
  scanner: cleanScanner,
287
285
  env: unusedVariables(app),
288
286
  challengeFetch: () => Promise.resolve(Response.json({ success: false, 'error-codes': ['invalid-input-response'] })),
@@ -328,6 +326,30 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
328
326
  )
329
327
  }
330
328
 
329
+ /** An endpoint of several capabilities, as one `ActionEndpoint` per capability, by its name. */
330
+ const capabilitiesEndpointOf = (id: string): ReadonlyMap<string, ActionEndpoint> => {
331
+ const row = kit.artifact.endpoints.find((candidate) => candidate.id === id)
332
+ const requirement = row?.requirement
333
+ if (row === undefined || requirement?.kind !== 'capabilities') {
334
+ throw new Error(`${id} is not an endpoint of the contract with several capabilities`)
335
+ }
336
+ return new Map(
337
+ Object.entries(requirement.capabilities).map(([name, capability]) => [name, {
338
+ id: row.id,
339
+ link: null,
340
+ method: row.method,
341
+ path: row.path,
342
+ callers: row.callers,
343
+ action: capability.action,
344
+ access: capability.access,
345
+ input: row.input,
346
+ directory: null,
347
+ manages: null,
348
+ links: [],
349
+ }]),
350
+ )
351
+ }
352
+
331
353
  const globalEndpointOf = (id: string): ActionEndpoint => {
332
354
  const row = endpointOf(id)
333
355
  if (row.access.form !== 'app' && row.access.form !== 'creates') {
@@ -384,11 +406,58 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
384
406
  return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
385
407
  }
386
408
 
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`)
409
+ // A seeded file column references an empty object, which a handler that serves or opens the file reads here.
410
+ let stocked = 0
411
+ const stockSeededObjects = (): void => {
412
+ const seeded = postgres.seededObjects()
413
+ for (const key of seeded.slice(stocked)) {
414
+ objects.objects.set(key, { bytes: new Uint8Array(0), contentType: 'application/octet-stream', version: SEEDED_OBJECT_VERSION })
390
415
  }
391
- const response = await fetch(row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
416
+ stocked = seeded.length
417
+ }
418
+
419
+ /** 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. */
420
+ const requestOf = (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): readonly [string, RequestInit] => {
421
+ if (row.method === 'GET') {
422
+ const params = new URLSearchParams(Object.entries(body).map(([name, value]) => [name, value === null ? 'null' : String(value)]))
423
+ return [params.size === 0 ? row.path : `${row.path}?${params}`, { method: 'GET' }]
424
+ }
425
+ const fields = fileFields(row.input)
426
+ if (fields.size === 0) {
427
+ return [row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }]
428
+ }
429
+ const json: Record<string, unknown> = {}
430
+ const files: [string, File][] = []
431
+ for (const [name, value] of Object.entries(body)) {
432
+ if (!fields.has(name)) {
433
+ json[name] = value
434
+ continue
435
+ }
436
+ for (const item of Array.isArray(value) ? value : [value]) {
437
+ const kind = fileKindOf(item)
438
+ if (kind === undefined) throw new Error(`${row.id}: the file field ${name} holds ${JSON.stringify(item)}, not a generated file`)
439
+ files.push([name, generatedFile(kind)])
440
+ }
441
+ }
442
+ const form = new FormData()
443
+ form.append(FILE_INPUT_PART, JSON.stringify(json))
444
+ for (const [name, file] of files) form.append(name, file)
445
+ return [row.path, { method: 'POST', body: form }]
446
+ }
447
+
448
+ const generatedFile = (kind: string): File => {
449
+ const sample = sampleFile(kind, kit.artifact.fileKinds?.[kind]?.types ?? [])
450
+ return new File([sample.bytes], `generated-${kind}`, { type: sample.type })
451
+ }
452
+
453
+ const askAs = async (
454
+ row: ActionEndpoint,
455
+ fetch: RpcFetch,
456
+ body: Readonly<Record<string, unknown>>,
457
+ ): Promise<{ readonly status: number; readonly text: string }> => {
458
+ stockSeededObjects()
459
+ const [url, init] = requestOf(row, body)
460
+ const response = await fetch(url, init)
392
461
  const text = await response.text()
393
462
  // A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
394
463
  // the input it was given is no answer to another scope. Either is what the generator could not build.
@@ -405,39 +474,6 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
405
474
  return { status: response.status, text }
406
475
  }
407
476
 
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
477
  /**
442
478
  * Two scopes of `dimension`: rows of the table that owns it when one does, with `seed`, otherwise two fresh scope
443
479
  * values. Each row `input` names in the object (`rows`) is seeded in each scope's row.
@@ -450,25 +486,10 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
450
486
  input: Readonly<Record<string, unknown>>,
451
487
  ): Promise<Scopes> => {
452
488
  const named = rowFields(input)
453
- const collection = Object.keys(kit.artifact.files).find((name) => dimension === `file:${name}`)
454
489
  const owner = ownerOf(dimension)
455
- if (named.length > 0 && (collection !== undefined || owner === undefined)) {
490
+ if (named.length > 0 && owner === undefined) {
456
491
  throw new Error(`no table owns ${dimension}, so the kit seeds no ${named.map((row) => row.table).join(', ')} row in one`)
457
492
  }
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
493
  if (owner === undefined) {
473
494
  const own = freshValue(dimension, 'own', field)
474
495
  const other = freshValue(dimension, 'other', field)
@@ -773,43 +794,60 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
773
794
  )
774
795
  return { status }
775
796
  },
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
797
  async scoped(endpoint, input) {
802
798
  const row = globalEndpointOf(endpoint)
803
799
  const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
804
800
  const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
805
801
  return { status }
806
802
  },
803
+ async anyone(endpoint, input) {
804
+ const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
805
+ if (row?.kind !== 'public') {
806
+ throw new Error(`${endpoint} is not a public procedure of the contract`)
807
+ }
808
+ const response = await kit.fetch(row.path, {
809
+ method: row.method,
810
+ headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
811
+ body: JSON.stringify(input),
812
+ })
813
+ const status = response.status
814
+ const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
815
+ return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
816
+ },
807
817
  async global(endpoint, input) {
808
818
  const row = globalEndpointOf(endpoint)
809
819
  const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
810
820
  // Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
811
821
  return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
812
822
  },
823
+ async capability(endpoint, capability, input, seed = {}) {
824
+ const capabilities = capabilitiesEndpointOf(endpoint)
825
+ const named = capabilities.get(capability)
826
+ if (named === undefined) {
827
+ throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
828
+ }
829
+ const grants: Grant[] = []
830
+ const global: { readonly action: string }[] = []
831
+ let asked = input
832
+ for (const [name, other] of capabilities) {
833
+ if (name === capability) continue
834
+ if (other.access.form === 'over') {
835
+ global.push({ action: other.action })
836
+ } else if (other.access.form === 'on' || other.access.form === 'in') {
837
+ const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
838
+ asked = addressing(asked, other.access.from, own)
839
+ grants.push(...own.grants)
840
+ }
841
+ }
842
+ const { access } = named
843
+ if (access.form === 'on' || access.form === 'in') {
844
+ const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
845
+ asked = addressing(asked, access.from, scopes.other)
846
+ grants.push(...scopes.own.grants)
847
+ }
848
+ const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
849
+ return { status }
850
+ },
813
851
  }
814
852
  // Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one.
815
853
  const drained = <A extends readonly unknown[], R>(method: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
@@ -823,8 +861,9 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
823
861
  denied: drained(accessKit.denied),
824
862
  leaked: drained(accessKit.leaked),
825
863
  own: drained(accessKit.own),
826
- file: drained(accessKit.file),
827
864
  scoped: drained(accessKit.scoped),
828
865
  global: drained(accessKit.global),
866
+ anyone: drained(accessKit.anyone),
867
+ capability: drained(accessKit.capability),
829
868
  })
830
869
  }