@fabrika/app 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +746 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +308 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +331 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +824 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +760 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,809 @@
1
+ // The endpoint access kit: the application's own `serve(…)`, booted by `testKit` on its own data layer, asked by
2
+ // callers of two scopes that share no value. A caller of one scope that names another scope's object or value must be
3
+ // refused by the access form; an answer to one scope must hold nothing of another, also right after that other scope
4
+ // was answered (a cache keyed by nothing); a caller that holds only the endpoint's action, or a link holder, reaches its
5
+ // own target. The author's seed sets the state a precondition needs. It cannot see a bug that needs other data or order.
6
+
7
+ import {
8
+ type AccessForm,
9
+ type Caller,
10
+ type ContractArtifact,
11
+ type EndpointDirectory,
12
+ endpointDirectory,
13
+ endpointMembers,
14
+ type EndpointMethod,
15
+ type FileCollectionArtifact,
16
+ type SchemaShape,
17
+ type Scope,
18
+ } from '@fabrika/auth-core'
19
+ import type { Tables } from '@fabrika/db'
20
+ import { tablePolicies } from '@fabrika/db/tables'
21
+ import type { IsolationWorlds, SampleValue, TestDatabase, WorldRow } from '@fabrika/db/testing'
22
+ import { MemoryFileObjects } from '@fabrika/platform/testing'
23
+ import { z } from 'zod/v4'
24
+ import type { RpcFetch } from '../client/transport'
25
+ import { compileContract } from '../contract/compile'
26
+ import { GENERATED_INVITATION, GENERATED_LINK, GENERATED_MEMBER, GENERATED_ROW_PREFIX } from '../contract/defaults'
27
+ import type { AnyContract } from '../contract/define'
28
+ import { FILE_SCAN_JOB, type FileScanner, type ScanVerdict } from '../files/scan'
29
+ import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
30
+ import type { TestCaller, TestGrant } from './caller'
31
+ import { standInIamKit } from './kit'
32
+ import { scopeTypeOf } from './policy-isolation'
33
+
34
+ export interface DeniedOutcome {
35
+ readonly status: number
36
+ }
37
+
38
+ /**
39
+ * `answered` when every answer to the own scope is one the application gave: a 2xx, or a 4xx other than 401, 403 and
40
+ * 404 (a precondition's 409 included). Otherwise `status` is the first other answer. `leaked` are the values of the
41
+ * other scope's rows that the answers held: keys, parents, scope values and text markers.
42
+ */
43
+ export type LeakOutcome =
44
+ | { readonly answered: true; readonly leaked: readonly string[] }
45
+ | { readonly answered: false; readonly status: number; readonly leaked: readonly string[] }
46
+
47
+ /** `answered` unless the caller got a 401, a 403 or a 5xx; otherwise that status. */
48
+ export type ReachOutcome = { readonly answered: true } | { readonly answered: false; readonly status: number }
49
+
50
+ /** Column → value of the row of the table that owns the endpoint's dimension, in both scopes: the author's seed. */
51
+ export type Seed = Readonly<Record<string, SampleValue>>
52
+
53
+ export interface EndpointAccessKit {
54
+ /**
55
+ * A caller holding the endpoint's action in one scope names an object (`on`) or a scope value (`in`) of another;
56
+ * `input` is the rest of a valid input, and its `from` field is replaced.
57
+ */
58
+ denied(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
59
+ /**
60
+ * A caller of the other scope asks first, then a caller of one scope asks for its own object (`on`), its list
61
+ * (`over`) or, for every table that lies in the dimension, its scope value (`in`).
62
+ */
63
+ leaked(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<LeakOutcome>
64
+ /**
65
+ * A caller that holds only the endpoint's action asks for its own object: a person or a script in its scope, or, on
66
+ * a link mount, the holder of a link bound to the object. A write action reads its own target. Where IAM requires more
67
+ * of the caller, it holds that too: a member manager an assigning role and the assigned role's actions, a link's
68
+ * issuer the actions the link grants.
69
+ */
70
+ 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
+ /**
78
+ * An `app` or a `creates` endpoint asked by a caller who holds its action in one value of a dimension only, never
79
+ * globally: refused (403), by the proxy for `app` and by IAM for `creates`, since both need a global grant.
80
+ */
81
+ scoped(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<DeniedOutcome>
82
+ /**
83
+ * An `app` or a `creates` endpoint asked by a caller who holds its action as a global grant, and nothing else:
84
+ * `answered` unless the answer is a 401, a 403 or a 5xx.
85
+ */
86
+ global(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>
87
+ }
88
+
89
+ export type FileOperation = 'upload' | 'download' | 'remove'
90
+
91
+ interface ActionEndpoint {
92
+ readonly id: string
93
+ /** The link mount of a link procedure; `null` for every other endpoint. */
94
+ readonly link: string | null
95
+ readonly method: EndpointMethod
96
+ readonly path: string
97
+ readonly callers: readonly Caller[]
98
+ readonly action: string
99
+ readonly access: AccessForm
100
+ readonly input: SchemaShape | null
101
+ /** The member directory the endpoint reads, or `null`. */
102
+ readonly directory: EndpointDirectory | null
103
+ /** Its dimension when its action is the dimension's `members.manage`, which IAM checks on the value itself; else `null`. */
104
+ readonly manages: string | null
105
+ /** The link mounts it issues for its `Ref`: their names, and the actions they grant, which IAM requires of the issuer. */
106
+ readonly links: readonly { readonly mount: string; readonly grants: readonly string[] }[]
107
+ }
108
+
109
+ /** Every dimension value a target lies in, its own first: where a member directory of its scope is found. */
110
+ type Chain = readonly { readonly type: string; readonly value: string }[]
111
+
112
+ /**
113
+ * One scope a case asks from: its target, the grants a caller of it holds, the target's chain, and the key of each row
114
+ * the case names in its target (`rows`), by input field.
115
+ */
116
+ interface Side {
117
+ readonly target: string
118
+ readonly grants: readonly Grant[]
119
+ readonly chain: Chain
120
+ readonly rows: Readonly<Record<string, string>>
121
+ }
122
+
123
+ /** The two scopes a case asks from, and the values the other's rows hold. */
124
+ interface Scopes {
125
+ readonly own: Side
126
+ readonly other: Side & { readonly values: readonly string[] }
127
+ }
128
+
129
+ /** The input fields that name a row the kit seeds in the case's object (`rows`), with the row's table. */
130
+ function rowFields(input: Readonly<Record<string, unknown>>): readonly { readonly field: string; readonly table: string }[] {
131
+ return Object.entries(input).flatMap(([field, value]) =>
132
+ typeof value === 'string' && value.startsWith(GENERATED_ROW_PREFIX) ? [{ field, table: value.slice(GENERATED_ROW_PREFIX.length) }] : []
133
+ )
134
+ }
135
+
136
+ /** A case's input, and the values it named that only its own scope may see in an answer: a generated member's id and label. */
137
+ interface Addressed {
138
+ readonly input: Readonly<Record<string, unknown>>
139
+ readonly marks: readonly string[]
140
+ }
141
+
142
+ /** One target of an `in` report, with its chain from the target outward. */
143
+ interface InTarget {
144
+ readonly target: string
145
+ readonly chain: Chain
146
+ }
147
+
148
+ export type Grant =
149
+ | { readonly action: string; readonly scope: { readonly type: string; readonly value: string } }
150
+ | { readonly role: string; readonly scope: { readonly type: string; readonly value: string } }
151
+
152
+ const randomHex = (): string => crypto.randomUUID().replaceAll('-', '').slice(0, 8)
153
+
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'])
171
+
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`)
179
+ }
180
+
181
+ const uploadedSchema = z.strictObject({ result: z.strictObject({ id: z.string() }) })
182
+ const errorSchema = z.object({ error: z.object({ type: z.string() }) })
183
+
184
+ /** The `type` of an error envelope, or `undefined` for any other body. */
185
+ function errorType(text: string): string | undefined {
186
+ try {
187
+ const parsed = errorSchema.safeParse(JSON.parse(text))
188
+ return parsed.success ? parsed.data.error.type : undefined
189
+ } catch {
190
+ return undefined
191
+ }
192
+ }
193
+
194
+ /** A scope value no table holds, in the format of the input field it is sent in. */
195
+ function freshValue(dimension: string, world: 'own' | 'other', field: SchemaShape | undefined): string {
196
+ const format = field?.type === 'string' ? field.format : null
197
+ if (format === 'uuid') return crypto.randomUUID()
198
+ if (format === 'email') return `${dimension}-${world}-${randomHex()}@example.test`
199
+ return `${dimension}-${world}-${randomHex()}`
200
+ }
201
+
202
+ /** The shape of the input field a scope value is read from. */
203
+ function fieldOf(input: SchemaShape | null, from: string | null): SchemaShape | undefined {
204
+ return input?.type === 'object' && from !== null ? input.fields[from] : undefined
205
+ }
206
+
207
+ /** Every value of the other world a leak shows: its key, parent, every dimension value it lies in, and its markers. */
208
+ function worldValues(worlds: IsolationWorlds, world: 'own' | 'other'): string[] {
209
+ const row = worlds[world]
210
+ return [...new Set([row.key, row.scope, ...(row.parent === null ? [] : [row.parent]), ...row.chain.map((link) => link.value), ...row.markers])]
211
+ }
212
+
213
+ /** The cases' scanner: a file it reads in full is clean, so a seeded upload of a `scan: true` collection is served. */
214
+ const cleanScanner: FileScanner = {
215
+ async scan(objects, signal) {
216
+ const verdicts = new Map<string, ScanVerdict>()
217
+ for (const object of objects) {
218
+ try {
219
+ await new Response(await object.open(signal)).arrayBuffer()
220
+ verdicts.set(object.id, { kind: 'clean' })
221
+ } catch {
222
+ // No verdict: the file stays quarantined, and its case answers the 409 that shows it.
223
+ }
224
+ }
225
+ return { ok: true, verdicts }
226
+ },
227
+ }
228
+
229
+ /**
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.
233
+ */
234
+ function unusedVariables(app: ContractImplementation<AnyContract>): ApplicationEnvironment {
235
+ 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']))
243
+ }
244
+
245
+ /**
246
+ * The least a member manager holds in `scope` for IAM to let it give or remove the roles a case may ask for: the
247
+ * endpoint's action, one assigning role of each (`assignableBy`) that adds the fewest actions beyond those, and the
248
+ * actions of each as plain grants. The roles a case asks for are the membership roles its input names; an input that
249
+ * names none may ask for any, so the caller then covers every membership role of the dimension. Where one role assigns
250
+ * them all, it is the only role held. Every role is held in `scope`, an application-wide assigner too: IAM counts any
251
+ * assigning role held there, and a grant in `scope` reaches no other value.
252
+ */
253
+ export const managerGrants = (
254
+ artifact: Pick<ContractArtifact, 'roles'>,
255
+ action: string,
256
+ input: Readonly<Record<string, unknown>>,
257
+ scope: Scope,
258
+ ): Grant[] => {
259
+ const roles = artifact.roles
260
+ const members = Object.keys(roles).sort().filter((role) => roles[role]?.member?.scope === scope.type)
261
+ const named = members.filter((role) => Object.values(input).includes(role))
262
+ const targets = named.length > 0 ? named : members
263
+ const assignersOf = (role: string): readonly string[] => roles[role]?.member?.assignableBy ?? []
264
+ const actions = [action, ...targets.flatMap((target) => roles[target]?.grants ?? [])]
265
+ // An assigner's cost is what it grants beyond the actions the caller holds anyway; a tie goes to the first name.
266
+ const extra = (role: string): number => (roles[role]?.grants ?? []).filter((granted) => !actions.includes(granted)).length
267
+ const narrowest = (candidates: readonly string[]): string | undefined =>
268
+ [...candidates].sort((left, right) => extra(left) - extra(right) || (left < right ? -1 : left > right ? 1 : 0))[0]
269
+ const common = narrowest(Object.keys(roles).sort().filter((role) => targets.every((target) => assignersOf(target).includes(role))))
270
+ const assigners = common === undefined ? targets.flatMap((target) => narrowest(assignersOf(target)) ?? []) : [common]
271
+ return [
272
+ ...[...new Set(actions)].map((held) => ({ action: held, scope })),
273
+ ...[...new Set(assigners)].map((role) => ({ role, scope })),
274
+ ]
275
+ }
276
+
277
+ /**
278
+ * The endpoint access kit of `app`, whose data layer is `scoped(tables)`, over `postgres`. It boots `app` once, so the
279
+ * application's layer is bound here and nowhere else in the process.
280
+ */
281
+ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase): EndpointAccessKit {
282
+ // The objects of the files the cases seed live in memory; their rows are the application's own tables.
283
+ const { kit, standIn } = standInIamKit(app, {
284
+ database: postgres.database,
285
+ files: new MemoryFileObjects(),
286
+ scanner: cleanScanner,
287
+ env: unusedVariables(app),
288
+ challengeFetch: () => Promise.resolve(Response.json({ success: false, 'error-codes': ['invalid-input-response'] })),
289
+ })
290
+ const policies = tablePolicies(tables)
291
+ const tableNames = Object.keys(policies).sort()
292
+
293
+ const ownerOf = (dimension: string): string | undefined =>
294
+ tableNames.find((name) => {
295
+ const scope = policies[name]?.scope
296
+ return scope?.mode === 'dimension' && scope.dimension === dimension
297
+ })
298
+
299
+ const endpointOf = (id: string): ActionEndpoint => {
300
+ const row = kit.artifact.endpoints.find((candidate) => candidate.id === id)
301
+ const requirement = row?.requirement
302
+ if (row === undefined || requirement === null || requirement === undefined || requirement.kind !== 'action' || row.callers.length === 0) {
303
+ throw new Error(`${id} is not an endpoint of the contract with an action requirement`)
304
+ }
305
+ return {
306
+ id: row.id,
307
+ link: row.callers.some((caller) => caller === 'link') ? row.source.name : null,
308
+ method: row.method,
309
+ path: row.path,
310
+ callers: row.callers,
311
+ action: requirement.action,
312
+ access: requirement.access,
313
+ input: row.input,
314
+ directory: endpointDirectory(kit.artifact, row),
315
+ manages: endpointMembers(kit.artifact, row) === 'manage' && 'dimension' in requirement.access ? requirement.access.dimension : null,
316
+ links: linksOf(row.id, requirement.action, requirement.access),
317
+ }
318
+ }
319
+
320
+ /** The link mounts an endpoint's `Ref` issues for: its dimension's whose `issuedBy` is its action. */
321
+ const linksOf = (id: string, action: string, access: AccessForm): ActionEndpoint['links'] => {
322
+ if (access.form !== 'on' && access.form !== 'in') return []
323
+ const issues = kit.artifact.declared?.[id]?.issues
324
+ return Object.entries(kit.artifact.mounts).flatMap(([name, mount]) =>
325
+ mount.kind === 'link' && mount.dimension === access.dimension && mount.issuedBy === action && (issues === undefined || issues === name)
326
+ ? [{ mount: name, grants: mount.grants }]
327
+ : []
328
+ )
329
+ }
330
+
331
+ const globalEndpointOf = (id: string): ActionEndpoint => {
332
+ const row = endpointOf(id)
333
+ if (row.access.form !== 'app' && row.access.form !== 'creates') {
334
+ throw new Error(`${id} has the access form ${row.access.form}; only app and creates need the action held globally`)
335
+ }
336
+ return row
337
+ }
338
+
339
+ /** The dimension a table's rows are listed by: a dimension table's container or its own; a child's parent's. */
340
+ const listDimensionOf = (name: string): string | undefined => {
341
+ const scope = policies[name]?.scope
342
+ if (scope?.mode === 'dimension') return scope.container?.dimension ?? scope.dimension
343
+ if (scope?.mode !== 'child') return undefined
344
+ const parent = policies[scope.parent]?.scope
345
+ return parent?.mode === 'dimension' ? (parent.container?.dimension ?? parent.dimension) : undefined
346
+ }
347
+
348
+ const callerFor = (
349
+ row: ActionEndpoint,
350
+ grants: readonly Grant[],
351
+ global: readonly { readonly action: string }[] = [],
352
+ ): { readonly caller: TestCaller; readonly fetch: RpcFetch } => {
353
+ const asGrants: TestGrant<AnyContract>[] = [
354
+ ...grants.map((grant) => ('role' in grant ? { role: grant.role, scope: grant.scope } : { action: grant.action, scope: grant.scope })),
355
+ ...global,
356
+ ]
357
+ if (row.callers.some((caller) => caller === 'human')) {
358
+ const caller = kit.testCaller(asGrants)
359
+ return { caller, fetch: kit.browser(caller) }
360
+ }
361
+ if (row.callers.some((caller) => caller === 'machine')) {
362
+ const caller = kit.testCaller(asGrants, { kind: 'machine' })
363
+ return {
364
+ caller,
365
+ fetch: (input, init) => {
366
+ const headers = new Headers(init?.headers)
367
+ headers.set('Authorization', `Bearer ${caller.key ?? ''}`)
368
+ return kit.fetch(input, { ...init, headers })
369
+ },
370
+ }
371
+ }
372
+ throw new Error(`${row.id} admits neither a human nor a machine caller`)
373
+ }
374
+
375
+ /** A case's request by a new caller holding `grants`, its generated fields filled for `chain` and that caller. */
376
+ const ask = async (
377
+ row: ActionEndpoint,
378
+ grants: readonly Grant[],
379
+ input: Readonly<Record<string, unknown>>,
380
+ chain: Chain,
381
+ ): Promise<{ readonly status: number; readonly text: string; readonly marks: readonly string[] }> => {
382
+ const asker = callerFor(row, grants)
383
+ const asked = withGenerated(row, input, chain, asker.caller)
384
+ return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
385
+ }
386
+
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`)
390
+ }
391
+ const response = await fetch(row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
392
+ const text = await response.text()
393
+ // A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
394
+ // the input it was given is no answer to another scope. Either is what the generator could not build.
395
+ if (response.status === 400) {
396
+ const type = errorType(text)
397
+ throw new Error(
398
+ type === 'validation'
399
+ ? `${row.id}: the case's input does not match the endpoint's input schema; correct its fixture (${text})`
400
+ : `${row.id}: the handler refused the case's input (400 ${
401
+ type ?? 'without a type'
402
+ }); the case cannot reach what it tests until its fixtures give what the handler needs: a picked person in members, a row of its object in rows, the state in seeds or the whole input in fixtures (${text})`,
403
+ )
404
+ }
405
+ return { status: response.status, text }
406
+ }
407
+
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
+ /**
442
+ * Two scopes of `dimension`: rows of the table that owns it when one does, with `seed`, otherwise two fresh scope
443
+ * values. Each row `input` names in the object (`rows`) is seeded in each scope's row.
444
+ */
445
+ const scopesOf = async (
446
+ action: string,
447
+ dimension: string,
448
+ field: SchemaShape | undefined,
449
+ seed: Seed,
450
+ input: Readonly<Record<string, unknown>>,
451
+ ): Promise<Scopes> => {
452
+ const named = rowFields(input)
453
+ const collection = Object.keys(kit.artifact.files).find((name) => dimension === `file:${name}`)
454
+ const owner = ownerOf(dimension)
455
+ if (named.length > 0 && (collection !== undefined || owner === undefined)) {
456
+ throw new Error(`no table owns ${dimension}, so the kit seeds no ${named.map((row) => row.table).join(', ')} row in one`)
457
+ }
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
+ if (owner === undefined) {
473
+ const own = freshValue(dimension, 'own', field)
474
+ const other = freshValue(dimension, 'other', field)
475
+ return {
476
+ own: { target: own, grants: [{ action, scope: { type: dimension, value: own } }], chain: [{ type: dimension, value: own }], rows: {} },
477
+ other: {
478
+ target: other,
479
+ grants: [{ action, scope: { type: dimension, value: other } }],
480
+ values: [other],
481
+ chain: [{ type: dimension, value: other }],
482
+ rows: {},
483
+ },
484
+ }
485
+ }
486
+ const worlds = await postgres.seedWorlds(tables, owner, seed)
487
+ const own = await rowsIn(worlds.own, named)
488
+ const other = await rowsIn(worlds.other, named)
489
+ return {
490
+ own: {
491
+ target: worlds.own.key,
492
+ grants: [{ action, scope: { type: worlds.dimension, value: worlds.own.scope } }],
493
+ chain: worlds.own.chain,
494
+ rows: own.rows,
495
+ },
496
+ other: {
497
+ target: worlds.other.key,
498
+ grants: [{ action, scope: { type: worlds.dimension, value: worlds.other.scope } }],
499
+ values: [...worldValues(worlds, 'other'), ...other.values],
500
+ chain: worlds.other.chain,
501
+ rows: other.rows,
502
+ },
503
+ }
504
+ }
505
+
506
+ /** One row of each named table seeded in `object`: its key by input field, and every key and marker it holds. */
507
+ const rowsIn = async (object: WorldRow, named: ReturnType<typeof rowFields>) => {
508
+ const rows: Record<string, string> = {}
509
+ const values: string[] = []
510
+ for (const { field, table } of named) {
511
+ const seeded = await postgres.seedUnder(tables, table, object)
512
+ rows[field] = seeded.key
513
+ values.push(seeded.key, ...seeded.markers)
514
+ }
515
+ return { rows, values }
516
+ }
517
+
518
+ /** For a list of `dimension`: one row in each scope of every table listed by it; each caller holds every scope of its side. */
519
+ const listScopesOf = async (action: string, dimension: string): Promise<Scopes> => {
520
+ const own: Grant[] = []
521
+ const other: Grant[] = []
522
+ const values: string[] = []
523
+ for (const name of tableNames) {
524
+ if (listDimensionOf(name) === dimension) {
525
+ const worlds = await postgres.seedWorlds(tables, name)
526
+ own.push({ action, scope: { type: dimension, value: worlds.own.scope } })
527
+ other.push({ action, scope: { type: dimension, value: worlds.other.scope } })
528
+ values.push(...worldValues(worlds, 'other'))
529
+ }
530
+ }
531
+ if (own.length === 0) {
532
+ return scopesOf(action, dimension, undefined, {}, {})
533
+ }
534
+ return { own: { target: '', grants: own, chain: [], rows: {} }, other: { target: '', grants: other, values, chain: [], rows: {} } }
535
+ }
536
+
537
+ /**
538
+ * For a report `in` `dimension`: one row in each scope of every table that lies in it, the table that owns it with
539
+ * `seed`. Each side's targets are its rows' values of `dimension`, and each caller holds all of them.
540
+ */
541
+ const inScopesOf = async (action: string, dimension: string, field: SchemaShape | undefined, seed: Seed) => {
542
+ const own: InTarget[] = []
543
+ const other: InTarget[] = []
544
+ const values: string[] = []
545
+ for (const name of tableNames) {
546
+ const mode = policies[name]?.scope.mode
547
+ if (mode !== 'dimension' && mode !== 'child') continue
548
+ const worlds = await postgres.seedWorlds(tables, name, name === ownerOf(dimension) ? seed : {})
549
+ const ownAt = worlds.own.chain.findIndex((link) => link.type === dimension)
550
+ const otherAt = worlds.other.chain.findIndex((link) => link.type === dimension)
551
+ const ownValue = worlds.own.chain[ownAt]?.value
552
+ const otherValue = worlds.other.chain[otherAt]?.value
553
+ if (ownValue !== undefined && otherValue !== undefined) {
554
+ own.push({ target: ownValue, chain: worlds.own.chain.slice(ownAt) })
555
+ other.push({ target: otherValue, chain: worlds.other.chain.slice(otherAt) })
556
+ values.push(...worldValues(worlds, 'other'))
557
+ }
558
+ }
559
+ if (own.length === 0) {
560
+ const scopes = await scopesOf(action, dimension, field, seed, {})
561
+ return { own: [scopes.own], other: [scopes.other], values: scopes.other.values }
562
+ }
563
+ return { own, other, values }
564
+ }
565
+
566
+ /** `input` addressing `side`: its target in the `from` field, and its own key in each field that names a row (`rows`). */
567
+ const addressing = (
568
+ input: Readonly<Record<string, unknown>>,
569
+ from: string | null,
570
+ side: { readonly target: string; readonly rows?: Readonly<Record<string, string>> },
571
+ ): Readonly<Record<string, unknown>> => ({ ...input, ...side.rows, ...(from === null ? {} : { [from]: side.target }) })
572
+
573
+ /**
574
+ * `input` with every field that holds `GENERATED_MEMBER` set to a new member of the directory's value in `chain`: a
575
+ * principal holding a membership role of the directory's dimension there, which the kit's directory answers. `marks`
576
+ * are the member's id and label, which an answer to the other scope must not hold.
577
+ */
578
+ const withMember = (row: ActionEndpoint, input: Readonly<Record<string, unknown>>, chain: Chain): Addressed => {
579
+ const fields = Object.keys(input).filter((field) => input[field] === GENERATED_MEMBER)
580
+ if (fields.length === 0) return { input, marks: [] }
581
+ const directory = row.directory
582
+ if (directory === null) {
583
+ throw new Error(`${row.id} reads no member directory, so no input field of it names a member (${fields.join(', ')})`)
584
+ }
585
+ const value = chain.find((link) => link.type === directory.dimension)?.value
586
+ if (value === undefined) {
587
+ throw new Error(`${row.id}: no table locates the ${directory.dimension} of its target, so the kit cannot make a member there`)
588
+ }
589
+ const role = Object.keys(kit.artifact.roles).sort().find((name) => kit.artifact.roles[name]?.member?.scope === directory.dimension)
590
+ if (role === undefined) {
591
+ throw new Error(`${row.id}: ${directory.dimension} has no membership role, so the kit cannot make a member there`)
592
+ }
593
+ const marker = randomHex() + randomHex()
594
+ const member = kit.testCaller([{ role, scope: { type: directory.dimension, value } }], {
595
+ id: `member-${marker}`,
596
+ label: `member-${marker}@example.test`,
597
+ })
598
+ return {
599
+ input: { ...input, ...Object.fromEntries(fields.map((field) => [field, member.id])) },
600
+ marks: [member.id, ...(member.label === null ? [] : [member.label])],
601
+ }
602
+ }
603
+
604
+ /**
605
+ * `input` with every field that holds `GENERATED_INVITATION` set to an invitation the stand-in IAM holds open in the
606
+ * managed value of `chain`, to a membership role the input names (else the dimension's first): what a cancel cancels.
607
+ */
608
+ const withInvitation = (row: ActionEndpoint, input: Readonly<Record<string, unknown>>, chain: Chain): Addressed => {
609
+ const fields = Object.keys(input).filter((field) => input[field] === GENERATED_INVITATION)
610
+ if (fields.length === 0) return { input, marks: [] }
611
+ const dimension = row.manages
612
+ if (dimension === null) {
613
+ throw new Error(`${row.id} manages no members, so no input field of it names an invitation (${fields.join(', ')})`)
614
+ }
615
+ const value = chain.find((link) => link.type === dimension)?.value
616
+ const roles = Object.keys(kit.artifact.roles).sort().filter((name) => kit.artifact.roles[name]?.member?.scope === dimension)
617
+ const role = roles.find((name) => Object.values(input).includes(name)) ?? roles[0]
618
+ if (value === undefined || role === undefined) {
619
+ throw new Error(`${row.id}: the kit locates no ${dimension} with a membership role to hold an invitation in`)
620
+ }
621
+ const id = standIn.seedInvitation({ type: dimension, value }, role, `invited-${randomHex()}@example.test`)
622
+ return { input: { ...input, ...Object.fromEntries(fields.map((field) => [field, id])) }, marks: [id] }
623
+ }
624
+
625
+ /**
626
+ * `input` with every field that holds `GENERATED_LINK` set to a share link of the endpoint's mount, bound to `chain`
627
+ * and issued by `issuer`, the case's caller: what a revoke revokes.
628
+ */
629
+ const withLink = (row: ActionEndpoint, input: Readonly<Record<string, unknown>>, chain: Chain, issuer: TestCaller): Addressed => {
630
+ const fields = Object.keys(input).filter((field) => input[field] === GENERATED_LINK)
631
+ if (fields.length === 0) return { input, marks: [] }
632
+ const [link] = row.links
633
+ if (link === undefined || chain.length === 0) {
634
+ throw new Error(`${row.id} issues no share link of its object, so no input field of it names one (${fields.join(', ')})`)
635
+ }
636
+ const id = standIn.seedLink(issuer.id, link.mount, chain)
637
+ return { input: { ...input, ...Object.fromEntries(fields.map((field) => [field, id])) }, marks: [id] }
638
+ }
639
+
640
+ /** `input` with every generated field filled: a member, an invitation and a link of `chain`, for the case's `caller`. */
641
+ const withGenerated = (row: ActionEndpoint, input: Readonly<Record<string, unknown>>, chain: Chain, caller: TestCaller): Addressed => {
642
+ const member = withMember(row, input, chain)
643
+ const invitation = withInvitation(row, member.input, chain)
644
+ const link = withLink(row, invitation.input, chain, caller)
645
+ return { input: link.input, marks: [...member.marks, ...invitation.marks, ...link.marks] }
646
+ }
647
+
648
+ /**
649
+ * The grants a case's caller holds: its side's, or, on an endpoint that reads a member directory, the action on the
650
+ * directory's value, where IAM checks a lookup and a list: a caller who may read it, still only in its own scope. On
651
+ * an endpoint that manages members it is the action on the target's own value with the least IAM requires there to
652
+ * assign or revoke a role (`managerGrants`). An endpoint that issues links adds the actions its mounts grant, which
653
+ * IAM requires of the issuer.
654
+ */
655
+ const holding = (row: ActionEndpoint, input: Readonly<Record<string, unknown>>, grants: readonly Grant[], chain: Chain): readonly Grant[] => {
656
+ const { directory, manages } = row
657
+ const at = (dimension: string) => chain.find((link) => link.type === dimension)?.value
658
+ const managed = manages === null ? undefined : at(manages)
659
+ const listed = directory === null ? undefined : at(directory.dimension)
660
+ let held = grants
661
+ if (manages !== null && managed !== undefined) {
662
+ held = managerGrants(kit.artifact, row.action, input, { type: manages, value: managed })
663
+ } else if (directory !== null && listed !== undefined) {
664
+ held = [{ action: row.action, scope: { type: directory.dimension, value: listed } }]
665
+ }
666
+ const scopes = held.flatMap((grant) => ('action' in grant ? [grant.scope] : []))
667
+ return [...held, ...row.links.flatMap((link) => link.grants.flatMap((action) => scopes.map((scope) => ({ action, scope }))))]
668
+ }
669
+
670
+ /** The outcome of the own scope's answers: answered unless one is a failure or an authorization refusal. */
671
+ const leakOutcome = (answers: readonly { readonly status: number; readonly text: string }[], values: readonly string[]): LeakOutcome => {
672
+ const leaked = values.filter((value) => answers.some((answer) => answer.text.includes(value)))
673
+ const refused = answers.find((answer) => answer.status >= 500 || answer.status === 401 || answer.status === 403 || answer.status === 404)
674
+ return refused === undefined ? { answered: true, leaked } : { answered: false, status: refused.status, leaked }
675
+ }
676
+
677
+ /** A link holder of `row`'s mount, its key bound to the own world's object, and the rows `input` names in that object. */
678
+ const linkHolder = async (
679
+ row: ActionEndpoint,
680
+ mount: string,
681
+ seed: Seed,
682
+ input: Readonly<Record<string, unknown>>,
683
+ ): Promise<{ readonly fetch: RpcFetch; readonly rows: Readonly<Record<string, string>> }> => {
684
+ const access = row.access
685
+ if (access.form !== 'on') {
686
+ throw new Error(`${row.id} is a link procedure, so its access form is on the link's object, not ${access.form}`)
687
+ }
688
+ const owner = ownerOf(access.dimension)
689
+ if (owner === undefined) {
690
+ throw new Error(`${row.id}: no table owns ${access.dimension}, so no link can be bound to one`)
691
+ }
692
+ const { own } = await postgres.seedWorlds(tables, owner, seed)
693
+ const holder = kit.testCaller([{ action: row.action, scope: { type: access.dimension, value: own.key } }], {
694
+ kind: 'link',
695
+ link: { mount, chain: own.chain },
696
+ })
697
+ const fetch: RpcFetch = (request, init) => {
698
+ const headers = new Headers(init?.headers)
699
+ headers.set('Authorization', `Bearer ${holder.key ?? ''}`)
700
+ return kit.fetch(request, { ...init, headers })
701
+ }
702
+ return { fetch, rows: (await rowsIn(own, rowFields(input))).rows }
703
+ }
704
+
705
+ const accessKit: EndpointAccessKit = {
706
+ async denied(endpoint, input, seed = {}) {
707
+ const row = endpointOf(endpoint)
708
+ const { action, access } = row
709
+ if (access.form !== 'on' && access.form !== 'in') {
710
+ throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
711
+ }
712
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
713
+ const asked = addressing(input, access.from, scopes.other)
714
+ const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
715
+ return { status }
716
+ },
717
+ async leaked(endpoint, input, seed = {}) {
718
+ const row = endpointOf(endpoint)
719
+ const { action, access } = row
720
+ if (access.form === 'in') {
721
+ const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed)
722
+ const grants = (targets: readonly InTarget[]): Grant[] =>
723
+ targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
724
+ const marks: string[] = []
725
+ for (const side of scopes.other) {
726
+ const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain)
727
+ marks.push(...answer.marks)
728
+ }
729
+ const answers = []
730
+ for (const side of scopes.own) {
731
+ answers.push(await ask(row, grants(scopes.own), addressing(input, access.from, side), side.chain))
732
+ }
733
+ return leakOutcome(answers, [...scopes.values, ...marks])
734
+ }
735
+ if (access.form !== 'on' && access.form !== 'over') {
736
+ throw new Error(`${endpoint} has the access form ${access.form}; only on, in and over answer with rows of a scope`)
737
+ }
738
+ const scopes = access.form === 'on'
739
+ ? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
740
+ : await listScopesOf(action, access.dimension)
741
+ const from = access.form === 'on' ? access.from : null
742
+ const other = await ask(
743
+ row,
744
+ holding(row, input, scopes.other.grants, scopes.other.chain),
745
+ addressing(input, from, scopes.other),
746
+ scopes.other.chain,
747
+ )
748
+ const answer = await ask(
749
+ row,
750
+ holding(row, input, scopes.own.grants, scopes.own.chain),
751
+ addressing(input, from, scopes.own),
752
+ scopes.own.chain,
753
+ )
754
+ return leakOutcome([answer], [...scopes.other.values, ...other.marks])
755
+ },
756
+ async own(endpoint, input, seed = {}) {
757
+ const row = endpointOf(endpoint)
758
+ const { action, access } = row
759
+ if (access.form !== 'on') {
760
+ throw new Error(`${endpoint} has the access form ${access.form}; only on addresses an object of its own`)
761
+ }
762
+ if (row.link !== null) {
763
+ const holder = await linkHolder(row, row.link, seed, input)
764
+ const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows })
765
+ return { status }
766
+ }
767
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
768
+ const { status } = await ask(
769
+ row,
770
+ holding(row, input, scopes.own.grants, scopes.own.chain),
771
+ addressing(input, access.from, scopes.own),
772
+ scopes.own.chain,
773
+ )
774
+ return { status }
775
+ },
776
+ async file(name, operation, caller) {
777
+ const { collection, worlds, own, other } = await seedFiles(name)
778
+ const operationAction = { upload: collection.write, download: collection.read, remove: collection.delete }[operation]
779
+ const grants = [{ action: caller === 'reader' ? collection.read : operationAction, scope: { type: worlds.dimension, value: worlds.own.scope } }]
780
+ const world = caller === 'other' ? 'other' : 'own'
781
+ const fetch = kit.browser(kit.testCaller(grants))
782
+ const row = fileRow(name, operation)
783
+ if (operation === 'upload') {
784
+ const file = sampleFile(name, collection.types)
785
+ const path = row.path.replace(':owner', encodeURIComponent(worlds[world].key))
786
+ return { status: (await fetch(path, { method: 'POST', headers: { 'Content-Type': file.type }, body: file.bytes })).status }
787
+ }
788
+ const fileId = world === 'own' ? own : other
789
+ if (operation === 'download') {
790
+ return { status: (await fetch(row.path.replace(':fileId', encodeURIComponent(fileId)))).status }
791
+ }
792
+ const body = JSON.stringify({ id: fileId })
793
+ return { status: (await fetch(row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })).status }
794
+ },
795
+ async scoped(endpoint, input) {
796
+ const row = globalEndpointOf(endpoint)
797
+ const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
798
+ const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
799
+ return { status }
800
+ },
801
+ async global(endpoint, input) {
802
+ const row = globalEndpointOf(endpoint)
803
+ const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
804
+ // Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
805
+ return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
806
+ },
807
+ }
808
+ return Object.freeze(accessKit)
809
+ }