@fabrika/app 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
package/src/react/provider.tsx
CHANGED
|
@@ -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<
|
|
65
|
+
attachment(procedure: unknown, hook: string): (input: unknown) => Promise<DownloadedFile>
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export interface ResolvedProcedure extends StoreTarget {
|
package/src/testing/dev.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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:
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
}
|