@fabrika/app 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -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 +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- 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 +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -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 +167 -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/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- 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/challenge.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
// A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev
|
|
1
|
+
// A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev`; and the solver of an ALTCHA
|
|
2
|
+
// challenge, which needs no stand-in: the application issues and verifies it itself.
|
|
2
3
|
|
|
4
|
+
import { type Challenge, solveChallenge } from 'altcha-lib'
|
|
5
|
+
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2'
|
|
6
|
+
import { z } from 'zod/v4'
|
|
7
|
+
import type { RpcFetch } from '../client/transport'
|
|
3
8
|
import type { ChallengeFetch } from '../implement/challenge'
|
|
4
9
|
|
|
5
10
|
/** The token Turnstile's always-passing test widget (site key `1x00000000000000000000AA`) hands out every time. */
|
|
@@ -8,3 +13,32 @@ export const TURNSTILE_TEST_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
|
|
|
8
13
|
/** Turnstile's siteverify that passes every token: a fresh answer for the host and the form the check expects. */
|
|
9
14
|
export const passingChallenge: ChallengeFetch = (_request, check) =>
|
|
10
15
|
Promise.resolve(Response.json({ success: true, challenge_ts: new Date().toISOString(), hostname: check.hostname, action: check.action }))
|
|
16
|
+
|
|
17
|
+
// Read as loosely as the widget reads it: the verification is what holds a challenge to its shape.
|
|
18
|
+
const issuedChallenge = z.object({
|
|
19
|
+
parameters: z.object({ algorithm: z.string(), nonce: z.string(), salt: z.string(), cost: z.number(), keyLength: z.number(), keyPrefix: z.string() })
|
|
20
|
+
.catchall(z.unknown()),
|
|
21
|
+
signature: z.string().optional(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetches a challenge from `url` through `fetch` and solves it as the widget does, answering the payload the page puts
|
|
26
|
+
* into `challengeToken`. The kit issues the cheapest challenge, so this takes milliseconds.
|
|
27
|
+
*/
|
|
28
|
+
export async function solveChallengeAt(fetch: RpcFetch, url: string): Promise<string> {
|
|
29
|
+
const response = await fetch(url, { headers: { Accept: 'application/json' } })
|
|
30
|
+
if (response.status !== 200) {
|
|
31
|
+
throw new Error(`the challenge at ${url} answered ${response.status}: ${await response.text()}`)
|
|
32
|
+
}
|
|
33
|
+
const parsed = issuedChallenge.safeParse(await response.json())
|
|
34
|
+
if (!parsed.success) {
|
|
35
|
+
throw new Error(`the challenge at ${url} is no ALTCHA challenge`)
|
|
36
|
+
}
|
|
37
|
+
const { parameters, signature } = parsed.data
|
|
38
|
+
const challenge: Challenge = { parameters: { ...parameters }, ...(signature === undefined ? {} : { signature }) }
|
|
39
|
+
const solution = await solveChallenge({ challenge, deriveKey, timeout: 10_000 })
|
|
40
|
+
if (solution === null) {
|
|
41
|
+
throw new Error(`the challenge at ${url} was not solved in time`)
|
|
42
|
+
}
|
|
43
|
+
return btoa(JSON.stringify({ challenge, solution }))
|
|
44
|
+
}
|
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 ProcedureChallenge,
|
|
16
16
|
type SchemaShape,
|
|
17
17
|
type Scope,
|
|
18
18
|
} from '@fabrika/auth-core'
|
|
19
19
|
import type { Tables } from '@fabrika/db'
|
|
20
20
|
import { tablePolicies } from '@fabrika/db/tables'
|
|
21
|
-
import type
|
|
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,20 @@ 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
|
-
|
|
233
|
+
const challengeVariables = (challenge: ProcedureChallenge): readonly string[] =>
|
|
234
|
+
challenge.provider === 'altcha' || challenge.siteKey === undefined ? [challenge.secret] : [challenge.secret, challenge.siteKey]
|
|
235
|
+
const names = Object.values(artifact.mounts).flatMap((mount): readonly string[] => mount.kind === 'spa' ? (mount.public ?? []) : [])
|
|
236
|
+
const procedures = Object.values(artifact.procedures ?? {}).flatMap((procedure) =>
|
|
237
|
+
procedure.challenge === null ? [] : challengeVariables(procedure.challenge)
|
|
238
|
+
)
|
|
239
|
+
// Long enough for an ALTCHA key, which boot refuses under 32 characters.
|
|
240
|
+
return Object.fromEntries([...names, ...procedures].map((name) => [name, 'unused-by-every-generated-case-of-the-kit']))
|
|
243
241
|
}
|
|
244
242
|
|
|
245
243
|
/**
|
|
@@ -280,9 +278,10 @@ export const managerGrants = (
|
|
|
280
278
|
*/
|
|
281
279
|
export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase): EndpointAccessKit {
|
|
282
280
|
// The objects of the files the cases seed live in memory; their rows are the application's own tables.
|
|
281
|
+
const objects = new MemoryFileObjects()
|
|
283
282
|
const { kit, standIn } = standInIamKit(app, {
|
|
284
283
|
database: postgres.database,
|
|
285
|
-
files:
|
|
284
|
+
files: objects,
|
|
286
285
|
scanner: cleanScanner,
|
|
287
286
|
env: unusedVariables(app),
|
|
288
287
|
challengeFetch: () => Promise.resolve(Response.json({ success: false, 'error-codes': ['invalid-input-response'] })),
|
|
@@ -328,6 +327,30 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
328
327
|
)
|
|
329
328
|
}
|
|
330
329
|
|
|
330
|
+
/** An endpoint of several capabilities, as one `ActionEndpoint` per capability, by its name. */
|
|
331
|
+
const capabilitiesEndpointOf = (id: string): ReadonlyMap<string, ActionEndpoint> => {
|
|
332
|
+
const row = kit.artifact.endpoints.find((candidate) => candidate.id === id)
|
|
333
|
+
const requirement = row?.requirement
|
|
334
|
+
if (row === undefined || requirement?.kind !== 'capabilities') {
|
|
335
|
+
throw new Error(`${id} is not an endpoint of the contract with several capabilities`)
|
|
336
|
+
}
|
|
337
|
+
return new Map(
|
|
338
|
+
Object.entries(requirement.capabilities).map(([name, capability]) => [name, {
|
|
339
|
+
id: row.id,
|
|
340
|
+
link: null,
|
|
341
|
+
method: row.method,
|
|
342
|
+
path: row.path,
|
|
343
|
+
callers: row.callers,
|
|
344
|
+
action: capability.action,
|
|
345
|
+
access: capability.access,
|
|
346
|
+
input: row.input,
|
|
347
|
+
directory: null,
|
|
348
|
+
manages: null,
|
|
349
|
+
links: [],
|
|
350
|
+
}]),
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
331
354
|
const globalEndpointOf = (id: string): ActionEndpoint => {
|
|
332
355
|
const row = endpointOf(id)
|
|
333
356
|
if (row.access.form !== 'app' && row.access.form !== 'creates') {
|
|
@@ -384,11 +407,58 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
384
407
|
return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
|
|
385
408
|
}
|
|
386
409
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
410
|
+
// A seeded file column references an empty object, which a handler that serves or opens the file reads here.
|
|
411
|
+
let stocked = 0
|
|
412
|
+
const stockSeededObjects = (): void => {
|
|
413
|
+
const seeded = postgres.seededObjects()
|
|
414
|
+
for (const key of seeded.slice(stocked)) {
|
|
415
|
+
objects.objects.set(key, { bytes: new Uint8Array(0), contentType: 'application/octet-stream', version: SEEDED_OBJECT_VERSION })
|
|
390
416
|
}
|
|
391
|
-
|
|
417
|
+
stocked = seeded.length
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The request of a case: a GET's input in its URL, a file field's marker as a file of its kind in a multipart body. */
|
|
421
|
+
const requestOf = (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): readonly [string, RequestInit] => {
|
|
422
|
+
if (row.method === 'GET') {
|
|
423
|
+
const params = new URLSearchParams(Object.entries(body).map(([name, value]) => [name, value === null ? 'null' : String(value)]))
|
|
424
|
+
return [params.size === 0 ? row.path : `${row.path}?${params}`, { method: 'GET' }]
|
|
425
|
+
}
|
|
426
|
+
const fields = fileFields(row.input)
|
|
427
|
+
if (fields.size === 0) {
|
|
428
|
+
return [row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }]
|
|
429
|
+
}
|
|
430
|
+
const json: Record<string, unknown> = {}
|
|
431
|
+
const files: [string, File][] = []
|
|
432
|
+
for (const [name, value] of Object.entries(body)) {
|
|
433
|
+
if (!fields.has(name)) {
|
|
434
|
+
json[name] = value
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
438
|
+
const kind = fileKindOf(item)
|
|
439
|
+
if (kind === undefined) throw new Error(`${row.id}: the file field ${name} holds ${JSON.stringify(item)}, not a generated file`)
|
|
440
|
+
files.push([name, generatedFile(kind)])
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const form = new FormData()
|
|
444
|
+
form.append(FILE_INPUT_PART, JSON.stringify(json))
|
|
445
|
+
for (const [name, file] of files) form.append(name, file)
|
|
446
|
+
return [row.path, { method: 'POST', body: form }]
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const generatedFile = (kind: string): File => {
|
|
450
|
+
const sample = sampleFile(kind, kit.artifact.fileKinds?.[kind]?.types ?? [])
|
|
451
|
+
return new File([sample.bytes], `generated-${kind}`, { type: sample.type })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const askAs = async (
|
|
455
|
+
row: ActionEndpoint,
|
|
456
|
+
fetch: RpcFetch,
|
|
457
|
+
body: Readonly<Record<string, unknown>>,
|
|
458
|
+
): Promise<{ readonly status: number; readonly text: string }> => {
|
|
459
|
+
stockSeededObjects()
|
|
460
|
+
const [url, init] = requestOf(row, body)
|
|
461
|
+
const response = await fetch(url, init)
|
|
392
462
|
const text = await response.text()
|
|
393
463
|
// A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
|
|
394
464
|
// the input it was given is no answer to another scope. Either is what the generator could not build.
|
|
@@ -405,39 +475,6 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
405
475
|
return { status: response.status, text }
|
|
406
476
|
}
|
|
407
477
|
|
|
408
|
-
const collectionOf = (name: string): FileCollectionArtifact => {
|
|
409
|
-
const collection = kit.artifact.files[name]
|
|
410
|
-
if (collection === undefined) throw new Error(`${name} is not a file collection of the contract`)
|
|
411
|
-
return collection
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const fileRow = (name: string, operation: FileOperation): { readonly method: EndpointMethod; readonly path: string } => {
|
|
415
|
-
const row = kit.artifact.endpoints.find((candidate) => candidate.id === `files.${name}.${operation}`)
|
|
416
|
-
if (row === undefined) throw new Error(`files.${name}.${operation} is not an endpoint of the contract`)
|
|
417
|
-
return { method: row.method, path: row.path }
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** One owner row in each scope, and one file of each, written through the upload endpoint as a person of that scope. */
|
|
421
|
-
const seedFiles = async (name: string) => {
|
|
422
|
-
const collection = collectionOf(name)
|
|
423
|
-
const worlds = await postgres.seedWorlds(tables, collection.owner)
|
|
424
|
-
const upload = fileRow(name, 'upload')
|
|
425
|
-
const put = async (world: 'own' | 'other'): Promise<string> => {
|
|
426
|
-
const caller = kit.testCaller([{ action: collection.write, scope: { type: worlds.dimension, value: worlds[world].scope } }])
|
|
427
|
-
const file = sampleFile(name, collection.types)
|
|
428
|
-
const response = await kit.browser(caller)(upload.path.replace(':owner', encodeURIComponent(worlds[world].key)), {
|
|
429
|
-
method: 'POST',
|
|
430
|
-
headers: { 'Content-Type': file.type },
|
|
431
|
-
body: file.bytes,
|
|
432
|
-
})
|
|
433
|
-
if (response.status !== 200) throw new Error(`files.${name}.upload: seeding a file of the ${world} scope answered ${response.status}`)
|
|
434
|
-
return uploadedSchema.parse(await response.json()).result.id
|
|
435
|
-
}
|
|
436
|
-
const seeded = { collection, worlds, own: await put('own'), other: await put('other') }
|
|
437
|
-
if (collection.scan === true) await kit.runJob(FILE_SCAN_JOB)
|
|
438
|
-
return seeded
|
|
439
|
-
}
|
|
440
|
-
|
|
441
478
|
/**
|
|
442
479
|
* Two scopes of `dimension`: rows of the table that owns it when one does, with `seed`, otherwise two fresh scope
|
|
443
480
|
* values. Each row `input` names in the object (`rows`) is seeded in each scope's row.
|
|
@@ -450,25 +487,10 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
450
487
|
input: Readonly<Record<string, unknown>>,
|
|
451
488
|
): Promise<Scopes> => {
|
|
452
489
|
const named = rowFields(input)
|
|
453
|
-
const collection = Object.keys(kit.artifact.files).find((name) => dimension === `file:${name}`)
|
|
454
490
|
const owner = ownerOf(dimension)
|
|
455
|
-
if (named.length > 0 &&
|
|
491
|
+
if (named.length > 0 && owner === undefined) {
|
|
456
492
|
throw new Error(`no table owns ${dimension}, so the kit seeds no ${named.map((row) => row.table).join(', ')} row in one`)
|
|
457
493
|
}
|
|
458
|
-
if (collection !== undefined) {
|
|
459
|
-
const seeded = await seedFiles(collection)
|
|
460
|
-
const grant = (world: 'own' | 'other'): Grant[] => [{ action, scope: { type: seeded.worlds.dimension, value: seeded.worlds[world].scope } }]
|
|
461
|
-
return {
|
|
462
|
-
own: { target: seeded.own, grants: grant('own'), chain: seeded.worlds.own.chain, rows: {} },
|
|
463
|
-
other: {
|
|
464
|
-
target: seeded.other,
|
|
465
|
-
grants: grant('other'),
|
|
466
|
-
values: [...worldValues(seeded.worlds, 'other'), seeded.other],
|
|
467
|
-
chain: seeded.worlds.other.chain,
|
|
468
|
-
rows: {},
|
|
469
|
-
},
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
494
|
if (owner === undefined) {
|
|
473
495
|
const own = freshValue(dimension, 'own', field)
|
|
474
496
|
const other = freshValue(dimension, 'other', field)
|
|
@@ -773,43 +795,60 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
773
795
|
)
|
|
774
796
|
return { status }
|
|
775
797
|
},
|
|
776
|
-
async file(name, operation, caller) {
|
|
777
|
-
if (operation === 'upload') {
|
|
778
|
-
// An upload needs owners only: a seeded file would hold the owner's place, and a collection of
|
|
779
|
-
// `maxPerOwner: 1` could never take the probe's.
|
|
780
|
-
const collection = collectionOf(name)
|
|
781
|
-
const worlds = await postgres.seedWorlds(tables, collection.owner)
|
|
782
|
-
const grants = [{ action: caller === 'reader' ? collection.read : collection.write, scope: { type: worlds.dimension, value: worlds.own.scope } }]
|
|
783
|
-
const file = sampleFile(name, collection.types)
|
|
784
|
-
const path = fileRow(name, 'upload').path.replace(':owner', encodeURIComponent(worlds[caller === 'other' ? 'other' : 'own'].key))
|
|
785
|
-
const fetch = kit.browser(kit.testCaller(grants))
|
|
786
|
-
return { status: (await fetch(path, { method: 'POST', headers: { 'Content-Type': file.type }, body: file.bytes })).status }
|
|
787
|
-
}
|
|
788
|
-
const { collection, worlds, own, other } = await seedFiles(name)
|
|
789
|
-
const operationAction = operation === 'download' ? collection.read : collection.delete
|
|
790
|
-
const grants = [{ action: caller === 'reader' ? collection.read : operationAction, scope: { type: worlds.dimension, value: worlds.own.scope } }]
|
|
791
|
-
const world = caller === 'other' ? 'other' : 'own'
|
|
792
|
-
const fetch = kit.browser(kit.testCaller(grants))
|
|
793
|
-
const row = fileRow(name, operation)
|
|
794
|
-
const fileId = world === 'own' ? own : other
|
|
795
|
-
if (operation === 'download') {
|
|
796
|
-
return { status: (await fetch(row.path.replace(':fileId', encodeURIComponent(fileId)))).status }
|
|
797
|
-
}
|
|
798
|
-
const body = JSON.stringify({ id: fileId })
|
|
799
|
-
return { status: (await fetch(row.path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })).status }
|
|
800
|
-
},
|
|
801
798
|
async scoped(endpoint, input) {
|
|
802
799
|
const row = globalEndpointOf(endpoint)
|
|
803
800
|
const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
|
|
804
801
|
const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
|
|
805
802
|
return { status }
|
|
806
803
|
},
|
|
804
|
+
async anyone(endpoint, input) {
|
|
805
|
+
const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
|
|
806
|
+
if (row?.kind !== 'public') {
|
|
807
|
+
throw new Error(`${endpoint} is not a public procedure of the contract`)
|
|
808
|
+
}
|
|
809
|
+
const response = await kit.fetch(row.path, {
|
|
810
|
+
method: row.method,
|
|
811
|
+
headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
|
|
812
|
+
body: JSON.stringify(input),
|
|
813
|
+
})
|
|
814
|
+
const status = response.status
|
|
815
|
+
const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
|
|
816
|
+
return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
|
|
817
|
+
},
|
|
807
818
|
async global(endpoint, input) {
|
|
808
819
|
const row = globalEndpointOf(endpoint)
|
|
809
820
|
const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
|
|
810
821
|
// Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
|
|
811
822
|
return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
|
|
812
823
|
},
|
|
824
|
+
async capability(endpoint, capability, input, seed = {}) {
|
|
825
|
+
const capabilities = capabilitiesEndpointOf(endpoint)
|
|
826
|
+
const named = capabilities.get(capability)
|
|
827
|
+
if (named === undefined) {
|
|
828
|
+
throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
|
|
829
|
+
}
|
|
830
|
+
const grants: Grant[] = []
|
|
831
|
+
const global: { readonly action: string }[] = []
|
|
832
|
+
let asked = input
|
|
833
|
+
for (const [name, other] of capabilities) {
|
|
834
|
+
if (name === capability) continue
|
|
835
|
+
if (other.access.form === 'over') {
|
|
836
|
+
global.push({ action: other.action })
|
|
837
|
+
} else if (other.access.form === 'on' || other.access.form === 'in') {
|
|
838
|
+
const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
|
|
839
|
+
asked = addressing(asked, other.access.from, own)
|
|
840
|
+
grants.push(...own.grants)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const { access } = named
|
|
844
|
+
if (access.form === 'on' || access.form === 'in') {
|
|
845
|
+
const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
|
|
846
|
+
asked = addressing(asked, access.from, scopes.other)
|
|
847
|
+
grants.push(...scopes.own.grants)
|
|
848
|
+
}
|
|
849
|
+
const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
|
|
850
|
+
return { status }
|
|
851
|
+
},
|
|
813
852
|
}
|
|
814
853
|
// Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one.
|
|
815
854
|
const drained = <A extends readonly unknown[], R>(method: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
|
|
@@ -823,8 +862,9 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
823
862
|
denied: drained(accessKit.denied),
|
|
824
863
|
leaked: drained(accessKit.leaked),
|
|
825
864
|
own: drained(accessKit.own),
|
|
826
|
-
file: drained(accessKit.file),
|
|
827
865
|
scoped: drained(accessKit.scoped),
|
|
828
866
|
global: drained(accessKit.global),
|
|
867
|
+
anyone: drained(accessKit.anyone),
|
|
868
|
+
capability: drained(accessKit.capability),
|
|
829
869
|
})
|
|
830
870
|
}
|