@fabrika/app 0.9.0-cloudflare.1 → 0.9.0-cloudflare.2
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 +4 -2
- package/skill/references/app-setup.md +29 -11
- package/skill/references/endpoint.md +5 -0
- package/skill/references/errors.md +31 -31
- package/skill/references/jobs.md +9 -0
- package/skill/references/tests.md +22 -1
- package/src/build.ts +6 -1
- package/src/bun.ts +10 -0
- package/src/cloudflare/application.ts +3 -3
- package/src/cloudflare/bindings.ts +26 -5
- package/src/cloudflare/names.ts +6 -0
- package/src/contract/artifact-schema.ts +6 -1
- package/src/contract/build.ts +33 -10
- package/src/contract/group.ts +10 -1
- package/src/contract/kit.ts +2 -1
- package/src/disk-assets.ts +8 -3
- package/src/implement/boot.ts +9 -3
- package/src/implement/capability.ts +3 -0
- package/src/implement/dispatch.ts +4 -4
- package/src/implement/egress.ts +341 -8
- package/src/implement/invocation.ts +10 -9
- package/src/jobs/runner.ts +7 -2
- package/src/spa-fallback.ts +12 -0
- package/src/testing/endpoint-access.ts +135 -23
- package/src/testing/isolation.ts +8 -1
- package/types/src/build.d.ts +1 -1
- package/types/src/cloudflare/bindings.d.ts +6 -0
- package/types/src/cloudflare/names.d.ts +5 -0
- package/types/src/contract/group.d.ts +9 -1
- package/types/src/contract/kit.d.ts +2 -2
- package/types/src/disk-assets.d.ts +4 -1
- package/types/src/implement/boot.d.ts +6 -0
- package/types/src/implement/capability.d.ts +2 -0
- package/types/src/implement/dispatch.d.ts +2 -1
- package/types/src/implement/egress.d.ts +36 -1
- package/types/src/implement/invocation.d.ts +3 -3
- package/types/src/jobs/runner.d.ts +3 -0
- package/types/src/spa-fallback.d.ts +2 -0
- package/types/src/testing/endpoint-access.d.ts +25 -1
- package/types/src/testing/isolation.d.ts +1 -1
|
@@ -20,6 +20,9 @@ import type { Tables } from '@fabrika/db'
|
|
|
20
20
|
import { tablePolicies } from '@fabrika/db/tables'
|
|
21
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
|
+
import { readFile } from 'node:fs/promises'
|
|
24
|
+
import { basename } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
23
26
|
import { z } from 'zod/v4'
|
|
24
27
|
import type { RpcFetch } from '../client/transport'
|
|
25
28
|
import { compileContract } from '../contract/compile'
|
|
@@ -33,6 +36,7 @@ import {
|
|
|
33
36
|
} from '../contract/defaults'
|
|
34
37
|
import type { AnyContract } from '../contract/define'
|
|
35
38
|
import type { FileScanner, ScanVerdict } from '../files/scan'
|
|
39
|
+
import { bytesAreOfType, SNIFF_BYTES } from '../files/sniff'
|
|
36
40
|
import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
|
|
37
41
|
import type { TestCaller, TestGrant } from './caller'
|
|
38
42
|
import { standInIamKit } from './kit'
|
|
@@ -110,6 +114,54 @@ export interface EndpointAccessKit {
|
|
|
110
114
|
anyone(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
export interface EndpointAccessOptions {
|
|
118
|
+
/**
|
|
119
|
+
* The file every case of an endpoint sends in a file field instead of the kit's sample, by endpoint id and input
|
|
120
|
+
* field: a file of the application's repository that a handler inspecting the content accepts (a valid package). Its
|
|
121
|
+
* type is the first of the field's kind that its bytes are; a file of none of them throws. `files` of the fixtures.
|
|
122
|
+
*/
|
|
123
|
+
readonly files?: Readonly<Record<string, Readonly<Record<string, URL>>>>
|
|
124
|
+
/**
|
|
125
|
+
* The error types of the refusals (400) an endpoint's handler answers its own object with when the case's input meets
|
|
126
|
+
* no domain rule the kit can satisfy, by endpoint id: such a 400 is the handler's own answer, so the request passed
|
|
127
|
+
* the access form, and the endpoint's own case gets it as `{ status: 400 }` instead of failing; so does the other
|
|
128
|
+
* scope's request a leak case makes first. The own scope's answer of a leak case, and a global or capability case,
|
|
129
|
+
* still throw on it (an answer that holds nothing proves no isolation), so does any other 400, and an input the schema
|
|
130
|
+
* refuses (`validation`) always does. `refusals` of the fixtures.
|
|
131
|
+
*/
|
|
132
|
+
readonly refusals?: Readonly<Record<string, readonly string[]>>
|
|
133
|
+
/**
|
|
134
|
+
* Known values for text columns of the row the endpoint addresses (the row of the table that owns its dimension), by
|
|
135
|
+
* endpoint id: the value in the own scope's row, and `<value>-other` in the other scope's, which counts among the
|
|
136
|
+
* other scope's values a leak shows. For a handler whose rule ties a column to the input or a file fixture (a package
|
|
137
|
+
* that names the skill's slug). `pins` of the fixtures.
|
|
138
|
+
*/
|
|
139
|
+
readonly pins?: Readonly<Record<string, Readonly<Record<string, string>>>>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a request takes a refusal the fixtures declare for its endpoint as the handler's answer: an own case's, and the
|
|
144
|
+
* other scope's request a leak case makes first, whose answer is not the one checked.
|
|
145
|
+
*/
|
|
146
|
+
type Refusals = 'declared' | 'none'
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The pins of a case's endpoint (column → the own scope's value of a text column), and the removals of the case: rows
|
|
150
|
+
* seeded with pins are removed once the case is over, so the next case can pin the same values under a unique index.
|
|
151
|
+
*/
|
|
152
|
+
interface Pinning {
|
|
153
|
+
readonly pins: Readonly<Record<string, string>>
|
|
154
|
+
readonly removals: (() => Promise<void>)[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const NO_PINNING: Pinning = { pins: {}, removals: [] }
|
|
158
|
+
|
|
159
|
+
/** Seeded worlds, their removal taken by the case when they hold pinned values. */
|
|
160
|
+
const pinned = <W extends { readonly remove: () => Promise<void> }>(worlds: W, pinning: Pinning): W => {
|
|
161
|
+
if (Object.keys(pinning.pins).length > 0) pinning.removals.push(worlds.remove)
|
|
162
|
+
return worlds
|
|
163
|
+
}
|
|
164
|
+
|
|
113
165
|
interface ActionEndpoint {
|
|
114
166
|
readonly id: string
|
|
115
167
|
/** The link mount of a link procedure; `null` for every other endpoint. */
|
|
@@ -285,7 +337,12 @@ export const managerGrants = (
|
|
|
285
337
|
* The endpoint access kit of `app`, whose data layer is `scoped(tables)`, over `postgres`. It boots `app` once, so the
|
|
286
338
|
* application's layer is bound here and nowhere else in the process.
|
|
287
339
|
*/
|
|
288
|
-
export function endpointAccessKit<DB, P>(
|
|
340
|
+
export function endpointAccessKit<DB, P>(
|
|
341
|
+
app: ContractImplementation<AnyContract>,
|
|
342
|
+
tables: Tables<DB, P>,
|
|
343
|
+
postgres: TestDatabase,
|
|
344
|
+
options: EndpointAccessOptions = {},
|
|
345
|
+
): EndpointAccessKit {
|
|
289
346
|
// The objects of the files the cases seed live in memory; their rows are the application's own tables.
|
|
290
347
|
const objects = new MemoryFileObjects()
|
|
291
348
|
const { kit, standIn } = standInIamKit(app, {
|
|
@@ -404,16 +461,20 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
404
461
|
throw new Error(`${row.id} admits neither a human nor a machine caller`)
|
|
405
462
|
}
|
|
406
463
|
|
|
407
|
-
/**
|
|
464
|
+
/**
|
|
465
|
+
* A case's request by a new caller holding `grants`, its generated fields filled for `chain` and that caller. Only an
|
|
466
|
+
* own case passes `refusals: 'declared'`.
|
|
467
|
+
*/
|
|
408
468
|
const ask = async (
|
|
409
469
|
row: ActionEndpoint,
|
|
410
470
|
grants: readonly Grant[],
|
|
411
471
|
input: Readonly<Record<string, unknown>>,
|
|
412
472
|
chain: Chain,
|
|
473
|
+
refusals: Refusals = 'none',
|
|
413
474
|
): Promise<{ readonly status: number; readonly text: string; readonly marks: readonly string[] }> => {
|
|
414
475
|
const asker = callerFor(row, grants)
|
|
415
476
|
const asked = withGenerated(row, input, chain, asker.caller)
|
|
416
|
-
return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
|
|
477
|
+
return { ...(await askAs(row, asker.fetch, asked.input, refusals)), marks: asked.marks }
|
|
417
478
|
}
|
|
418
479
|
|
|
419
480
|
// A seeded file column references an empty object, which a handler that serves or opens the file reads here.
|
|
@@ -427,7 +488,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
427
488
|
}
|
|
428
489
|
|
|
429
490
|
/** 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. */
|
|
430
|
-
const requestOf = (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): readonly [string, RequestInit] => {
|
|
491
|
+
const requestOf = async (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): Promise<readonly [string, RequestInit]> => {
|
|
431
492
|
if (row.method === 'GET') {
|
|
432
493
|
const params = new URLSearchParams(Object.entries(body).map(([name, value]) => [name, value === null ? 'null' : String(value)]))
|
|
433
494
|
return [params.size === 0 ? row.path : `${row.path}?${params}`, { method: 'GET' }]
|
|
@@ -446,7 +507,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
446
507
|
for (const item of Array.isArray(value) ? value : [value]) {
|
|
447
508
|
const kind = fileKindOf(item)
|
|
448
509
|
if (kind === undefined) throw new Error(`${row.id}: the file field ${name} holds ${JSON.stringify(item)}, not a generated file`)
|
|
449
|
-
files.push([name, generatedFile(kind)])
|
|
510
|
+
files.push([name, await generatedFile(row.id, name, kind)])
|
|
450
511
|
}
|
|
451
512
|
}
|
|
452
513
|
const form = new FormData()
|
|
@@ -455,30 +516,69 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
455
516
|
return [row.path, { method: 'POST', body: form }]
|
|
456
517
|
}
|
|
457
518
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
519
|
+
/** The file a case sends in `field`: the author's file fixture of the endpoint's field, else a sample of `kind`. */
|
|
520
|
+
const generatedFile = async (endpoint: string, field: string, kind: string): Promise<File> => {
|
|
521
|
+
const types = kit.artifact.fileKinds?.[kind]?.types ?? []
|
|
522
|
+
const fixture = options.files?.[endpoint]?.[field]
|
|
523
|
+
if (fixture === undefined) {
|
|
524
|
+
const sample = sampleFile(kind, types)
|
|
525
|
+
return new File([sample.bytes], `generated-${kind}`, { type: sample.type })
|
|
526
|
+
}
|
|
527
|
+
const path = fileURLToPath(fixture)
|
|
528
|
+
const bytes = await fixtureBytes(endpoint, field, path)
|
|
529
|
+
const type = types.find((candidate) => bytesAreOfType(candidate, bytes.subarray(0, SNIFF_BYTES)))
|
|
530
|
+
if (type === undefined) {
|
|
531
|
+
throw new Error(`${endpoint}: the file of ${field}, ${path}, is none of the types of files.${kind} (${types.join(', ')})`)
|
|
532
|
+
}
|
|
533
|
+
return new File([bytes], basename(path), { type })
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// A file fixture is read once; every case sends the same bytes.
|
|
537
|
+
const fixtureFiles = new Map<string, Uint8Array<ArrayBuffer>>()
|
|
538
|
+
const fixtureBytes = async (endpoint: string, field: string, path: string): Promise<Uint8Array<ArrayBuffer>> => {
|
|
539
|
+
const known = fixtureFiles.get(path)
|
|
540
|
+
if (known !== undefined) return known
|
|
541
|
+
let bytes: Uint8Array<ArrayBuffer>
|
|
542
|
+
try {
|
|
543
|
+
bytes = new Uint8Array(await readFile(path))
|
|
544
|
+
} catch {
|
|
545
|
+
throw new Error(`${endpoint}: the file of ${field} in the fixtures, ${path}, cannot be read; add it to the repository or correct its path`)
|
|
546
|
+
}
|
|
547
|
+
fixtureFiles.set(path, bytes)
|
|
548
|
+
return bytes
|
|
461
549
|
}
|
|
462
550
|
|
|
463
551
|
const askAs = async (
|
|
464
552
|
row: ActionEndpoint,
|
|
465
553
|
fetch: RpcFetch,
|
|
466
554
|
body: Readonly<Record<string, unknown>>,
|
|
555
|
+
refusals: Refusals = 'none',
|
|
467
556
|
): Promise<{ readonly status: number; readonly text: string }> => {
|
|
468
557
|
stockSeededObjects()
|
|
469
|
-
const [url, init] = requestOf(row, body)
|
|
558
|
+
const [url, init] = await requestOf(row, body)
|
|
470
559
|
const response = await fetch(url, init)
|
|
471
560
|
const text = await response.text()
|
|
472
561
|
// A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
|
|
473
562
|
// the input it was given is no answer to another scope. Either is what the generator could not build.
|
|
474
563
|
if (response.status === 400) {
|
|
475
564
|
const type = errorType(text)
|
|
565
|
+
const declared = type !== undefined && type !== 'validation' && options.refusals?.[row.id]?.includes(type) === true
|
|
566
|
+
// The own case may take it: it shows the access form let the caller through. The answer a leak case checks, or a
|
|
567
|
+
// global case's, would pass holding nothing, which proves nothing.
|
|
568
|
+
if (declared && refusals === 'declared') {
|
|
569
|
+
return { status: response.status, text }
|
|
570
|
+
}
|
|
571
|
+
if (declared) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`${row.id}: the handler answered its declared refusal (400 ${type}), which only the own case takes; this case proves nothing on it until the fixtures give what the handler needs (pins, files, fixtures) (${text})`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
476
576
|
throw new Error(
|
|
477
577
|
type === 'validation'
|
|
478
578
|
? `${row.id}: the case's input does not match the endpoint's input schema; correct its fixture (${text})`
|
|
479
579
|
: `${row.id}: the handler refused the case's input (400 ${
|
|
480
580
|
type ?? 'without a type'
|
|
481
|
-
}); 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
|
|
581
|
+
}); 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, a file it accepts in files, the whole input in fixtures, or the refusal it answers in refusals (${text})`,
|
|
482
582
|
)
|
|
483
583
|
}
|
|
484
584
|
return { status: response.status, text }
|
|
@@ -494,6 +594,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
494
594
|
field: SchemaShape | undefined,
|
|
495
595
|
seed: Seed,
|
|
496
596
|
input: Readonly<Record<string, unknown>>,
|
|
597
|
+
pinning: Pinning = NO_PINNING,
|
|
497
598
|
): Promise<Scopes> => {
|
|
498
599
|
const named = rowFields(input)
|
|
499
600
|
const owner = ownerOf(dimension)
|
|
@@ -514,7 +615,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
514
615
|
},
|
|
515
616
|
}
|
|
516
617
|
}
|
|
517
|
-
const worlds = await postgres.seedWorlds(tables, owner, seed)
|
|
618
|
+
const worlds = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
|
|
518
619
|
const own = await rowsIn(worlds.own, named)
|
|
519
620
|
const other = await rowsIn(worlds.other, named)
|
|
520
621
|
return {
|
|
@@ -569,14 +670,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
569
670
|
* For a report `in` `dimension`: one row in each scope of every table that lies in it, the table that owns it with
|
|
570
671
|
* `seed`. Each side's targets are its rows' values of `dimension`, and each caller holds all of them.
|
|
571
672
|
*/
|
|
572
|
-
const inScopesOf = async (action: string, dimension: string, field: SchemaShape | undefined, seed: Seed) => {
|
|
673
|
+
const inScopesOf = async (action: string, dimension: string, field: SchemaShape | undefined, seed: Seed, pinning: Pinning = NO_PINNING) => {
|
|
573
674
|
const own: InTarget[] = []
|
|
574
675
|
const other: InTarget[] = []
|
|
575
676
|
const values: string[] = []
|
|
576
677
|
for (const name of tableNames) {
|
|
577
678
|
const mode = policies[name]?.scope.mode
|
|
578
679
|
if (mode !== 'dimension' && mode !== 'child') continue
|
|
579
|
-
const
|
|
680
|
+
const owned = name === ownerOf(dimension)
|
|
681
|
+
const worlds = owned
|
|
682
|
+
? pinned(await postgres.seedWorlds(tables, name, seed, pinning.pins), pinning)
|
|
683
|
+
: await postgres.seedWorlds(tables, name)
|
|
580
684
|
const ownAt = worlds.own.chain.findIndex((link) => link.type === dimension)
|
|
581
685
|
const otherAt = worlds.other.chain.findIndex((link) => link.type === dimension)
|
|
582
686
|
const ownValue = worlds.own.chain[ownAt]?.value
|
|
@@ -588,7 +692,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
588
692
|
}
|
|
589
693
|
}
|
|
590
694
|
if (own.length === 0) {
|
|
591
|
-
const scopes = await scopesOf(action, dimension, field, seed, {})
|
|
695
|
+
const scopes = await scopesOf(action, dimension, field, seed, {}, pinning)
|
|
592
696
|
return { own: [scopes.own], other: [scopes.other], values: scopes.other.values }
|
|
593
697
|
}
|
|
594
698
|
return { own, other, values }
|
|
@@ -711,6 +815,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
711
815
|
mount: string,
|
|
712
816
|
seed: Seed,
|
|
713
817
|
input: Readonly<Record<string, unknown>>,
|
|
818
|
+
pinning: Pinning,
|
|
714
819
|
): Promise<{ readonly fetch: RpcFetch; readonly rows: Readonly<Record<string, string>> }> => {
|
|
715
820
|
const access = row.access
|
|
716
821
|
if (access.form !== 'on') {
|
|
@@ -720,7 +825,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
720
825
|
if (owner === undefined) {
|
|
721
826
|
throw new Error(`${row.id}: no table owns ${access.dimension}, so no link can be bound to one`)
|
|
722
827
|
}
|
|
723
|
-
const { own } = await postgres.seedWorlds(tables, owner, seed)
|
|
828
|
+
const { own } = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
|
|
724
829
|
const holder = kit.testCaller([{ action: row.action, scope: { type: access.dimension, value: own.key } }], {
|
|
725
830
|
kind: 'link',
|
|
726
831
|
link: { mount, chain: own.chain },
|
|
@@ -737,6 +842,9 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
737
842
|
// the call and the runs it kicked are over. A list per call, so a concurrent case never removes the rows another
|
|
738
843
|
// was given; the generated cases run one after another, since a row a required foreign key needed is shared.
|
|
739
844
|
const accessKitOf = (removals: (() => Promise<void>)[]): EndpointAccessKit => {
|
|
845
|
+
/** The pins of `endpoint`, whose seeded rows this call removes once it is over. */
|
|
846
|
+
const pinningOf = (endpoint: string): Pinning => ({ pins: options.pins?.[endpoint] ?? {}, removals })
|
|
847
|
+
|
|
740
848
|
/** One row of each application table `appRows` names, for this call only. */
|
|
741
849
|
const seedAppRows = async (appRows: AppRows): Promise<void> => {
|
|
742
850
|
if (Object.keys(appRows).length === 0) return
|
|
@@ -750,7 +858,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
750
858
|
if (access.form !== 'on' && access.form !== 'in') {
|
|
751
859
|
throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
|
|
752
860
|
}
|
|
753
|
-
const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
|
|
861
|
+
const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
|
|
754
862
|
const asked = addressing(input, access.from, scopes.other)
|
|
755
863
|
const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
|
|
756
864
|
return { status }
|
|
@@ -760,12 +868,12 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
760
868
|
const { action, access } = row
|
|
761
869
|
if (access.form === 'in') {
|
|
762
870
|
await seedAppRows(appRows)
|
|
763
|
-
const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed)
|
|
871
|
+
const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, pinningOf(endpoint))
|
|
764
872
|
const grants = (targets: readonly InTarget[]): Grant[] =>
|
|
765
873
|
targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
|
|
766
874
|
const marks: string[] = []
|
|
767
875
|
for (const side of scopes.other) {
|
|
768
|
-
const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain)
|
|
876
|
+
const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain, 'declared')
|
|
769
877
|
marks.push(...answer.marks)
|
|
770
878
|
}
|
|
771
879
|
const answers = []
|
|
@@ -779,14 +887,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
779
887
|
}
|
|
780
888
|
await seedAppRows(appRows)
|
|
781
889
|
const scopes = access.form === 'on'
|
|
782
|
-
? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
|
|
890
|
+
? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
|
|
783
891
|
: await listScopesOf(action, access.dimension)
|
|
784
892
|
const from = access.form === 'on' ? access.from : null
|
|
893
|
+
// The other scope asks first, to fill whatever a handler keeps between requests; its pins differ from the own
|
|
894
|
+
// scope's, so a refusal the fixtures declare is its answer too. The own scope's answer is the one checked.
|
|
785
895
|
const other = await ask(
|
|
786
896
|
row,
|
|
787
897
|
holding(row, input, scopes.other.grants, scopes.other.chain),
|
|
788
898
|
addressing(input, from, scopes.other),
|
|
789
899
|
scopes.other.chain,
|
|
900
|
+
'declared',
|
|
790
901
|
)
|
|
791
902
|
const answer = await ask(
|
|
792
903
|
row,
|
|
@@ -804,16 +915,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
804
915
|
}
|
|
805
916
|
await seedAppRows(appRows)
|
|
806
917
|
if (row.link !== null) {
|
|
807
|
-
const holder = await linkHolder(row, row.link, seed, input)
|
|
808
|
-
const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows })
|
|
918
|
+
const holder = await linkHolder(row, row.link, seed, input, pinningOf(endpoint))
|
|
919
|
+
const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows }, 'declared')
|
|
809
920
|
return { status }
|
|
810
921
|
}
|
|
811
|
-
const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
|
|
922
|
+
const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
|
|
812
923
|
const { status } = await ask(
|
|
813
924
|
row,
|
|
814
925
|
holding(row, input, scopes.own.grants, scopes.own.chain),
|
|
815
926
|
addressing(input, access.from, scopes.own),
|
|
816
927
|
scopes.own.chain,
|
|
928
|
+
'declared',
|
|
817
929
|
)
|
|
818
930
|
return { status }
|
|
819
931
|
},
|
|
@@ -864,7 +976,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
864
976
|
}
|
|
865
977
|
const { access } = named
|
|
866
978
|
if (access.form === 'on' || access.form === 'in') {
|
|
867
|
-
const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
|
|
979
|
+
const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input, pinningOf(endpoint))
|
|
868
980
|
asked = addressing(asked, access.from, scopes.other)
|
|
869
981
|
grants.push(...scopes.own.grants)
|
|
870
982
|
}
|
package/src/testing/isolation.ts
CHANGED
|
@@ -6,7 +6,14 @@ import { refuseInProduction } from '@fabrika/db/production'
|
|
|
6
6
|
|
|
7
7
|
refuseInProduction('@fabrika/app/testing/isolation')
|
|
8
8
|
|
|
9
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
type DeniedOutcome,
|
|
11
|
+
type EndpointAccessKit,
|
|
12
|
+
endpointAccessKit,
|
|
13
|
+
type EndpointAccessOptions,
|
|
14
|
+
type LeakOutcome,
|
|
15
|
+
type ReachOutcome,
|
|
16
|
+
} from './endpoint-access'
|
|
10
17
|
export {
|
|
11
18
|
type AppCaller,
|
|
12
19
|
type AppCountOutcome,
|
package/types/src/build.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare const GENERATED_MIGRATIONS_FILE = "fabrika/migrations.generated.t
|
|
|
10
10
|
export declare const MIGRATIONS_DIRECTORY = "db/migrations";
|
|
11
11
|
export type EntryRuntime = 'bun' | 'cloudflare';
|
|
12
12
|
export * from './build-recipe.js';
|
|
13
|
-
export { CLOUDFLARE_APPLICATION_BINDING, CLOUDFLARE_APPLICATION_CLASS, CLOUDFLARE_FILES_BINDING } from './cloudflare/names.js';
|
|
13
|
+
export { CLOUDFLARE_APPLICATION_BINDING, CLOUDFLARE_APPLICATION_CLASS, CLOUDFLARE_ASSETS_BINDING, CLOUDFLARE_FILES_BINDING, } from './cloudflare/names.js';
|
|
14
14
|
/** The entrypoint source for one runtime. */
|
|
15
15
|
export declare function generatedEntry(runtime: EntryRuntime): string;
|
|
16
16
|
/** Writes the entrypoint under `root` and returns its path; the Cloudflare one also writes the migrations module it imports. */
|
|
@@ -18,6 +18,12 @@ export declare function bindingTerminals(binding: unknown): IamTerminalRpc | und
|
|
|
18
18
|
* An answer that is not IAM's decided outcome throws: "IAM could not be asked".
|
|
19
19
|
*/
|
|
20
20
|
export declare function bindingWorkloadTokens(binding: unknown): WorkloadTokenSource | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* The SPA and link mounts' files from the Workers Assets binding, whose directory mirrors the request paths: the mount
|
|
23
|
+
* `/admin` reads `admin/…` of it, the root mount the rest (the Cloudflare deploy stages each mount's `dir` there). A page
|
|
24
|
+
* route that names no file (`isPageRoute`) gets the mount's `index.html`, as `diskAssets` serves it: Workers Assets
|
|
25
|
+
* answers `<prefix>/` with it. A missing asset stays 404.
|
|
26
|
+
*/
|
|
21
27
|
export declare function bindingAssets(binding: unknown): SpaAssets | undefined;
|
|
22
28
|
/**
|
|
23
29
|
* The service bindings to owners' proxy Workers (`FABRIKA_CONNECT_<IMPORT>`), which `captureEnvironment` keeps from
|
|
@@ -4,3 +4,8 @@ export declare const CLOUDFLARE_APPLICATION_CLASS = "FabrikaApplication";
|
|
|
4
4
|
export declare const CLOUDFLARE_APPLICATION_BINDING = "FABRIKA_APPLICATION";
|
|
5
5
|
/** The R2 bucket binding of the environment's stored files. */
|
|
6
6
|
export declare const CLOUDFLARE_FILES_BINDING = "FABRIKA_FILES";
|
|
7
|
+
/**
|
|
8
|
+
* The Workers Assets binding the SPA and link mounts serve their files from. Its directory mirrors the request paths:
|
|
9
|
+
* the mount `/admin` serves `<directory>/admin/…`, the root mount `<directory>/…`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const CLOUDFLARE_ASSETS_BINDING = "ASSETS";
|
|
@@ -74,7 +74,15 @@ export type SignsField<Sg, Purposes extends string> = {
|
|
|
74
74
|
* `fetch` refuses any other while the site runs; a public procedure declares none.
|
|
75
75
|
*/
|
|
76
76
|
export interface EgressField {
|
|
77
|
-
readonly egress?: readonly `https://${string}`[]
|
|
77
|
+
readonly egress?: readonly `https://${string}`[] | Unavailable<"egress: 'public' is a cron job's only: record a row and kick a job that declares it">;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A cron job's `egress`: its exact HTTPS origins, or `'public'`, every public host over HTTPS or HTTP on its default
|
|
81
|
+
* port, for a job that calls the websites its operators name (ADR-0070). The guard refuses IP addresses, local and
|
|
82
|
+
* internal names and the installation's own hosts, checks each redirect, and bounds each request's time and body.
|
|
83
|
+
*/
|
|
84
|
+
export interface JobEgressField {
|
|
85
|
+
readonly egress?: readonly `https://${string}`[] | 'public';
|
|
78
86
|
}
|
|
79
87
|
/** Each purpose `signs` names is one of `defineApp`'s `tokens`. */
|
|
80
88
|
type KnownPurposes<Sg, Purposes extends string> = Sg extends readonly (infer P extends string)[] ? [Exclude<P, Purposes>] extends [never] ? LiteralList<Sg> : Unavailable<`signs names ${ListOf<Exclude<P, Purposes>>}, which defineApp's tokens do not declare`> : unknown;
|
|
@@ -4,7 +4,7 @@ import type { z } from 'zod/v4';
|
|
|
4
4
|
import type { Declared, JobKitContext, KickContext, KitImports, KitTypes, NoTablesIndex, SqlMode, TokensContext, WebhookKitContext } from './context';
|
|
5
5
|
import { type ExportAccessDeclaration, type ImportDeclaration, type LinkAccessDeclaration, type ProcedureType } from './declaration';
|
|
6
6
|
import { type Contract, type CreatableDimension, type CronJob, type Export, type InputSchema, type JobLimitsOption, type Kit, type LinkMount, type NotLoose, type Path, type ProcedureTree, type RpcMount, type Schema, type Surface, type WebhookMount } from './define';
|
|
7
|
-
import type { DeclaredFields, EgressField, Endpoint, ExportKit, Group, GroupBuild, GroupInput, GroupKit, KicksField, LimitsOption, LinkGroupKit, ListOf, PendingIn, SignsField } from './group';
|
|
7
|
+
import type { DeclaredFields, EgressField, Endpoint, ExportKit, Group, GroupBuild, GroupInput, GroupKit, JobEgressField, KicksField, LimitsOption, LinkGroupKit, ListOf, PendingIn, SignsField } from './group';
|
|
8
8
|
import type { TokenPurposes } from './token';
|
|
9
9
|
import type { AnyAppVocabulary, AppVocabulary } from './vocabulary';
|
|
10
10
|
/** Fields a builder sets itself: an options object that carries one does not type-check. */
|
|
@@ -31,7 +31,7 @@ export type CronOptions<Acts, S, U, Is, Ix extends AnyDbIndex, M = never, Ks = n
|
|
|
31
31
|
readonly actions: Acts;
|
|
32
32
|
readonly limits?: JobLimitsOption;
|
|
33
33
|
readonly retries?: number;
|
|
34
|
-
} & DeclaredFields<S, U, never, Is, Ix> & MailerField<M> & KicksField<Ks> & SignsField<Sg, Purposes> &
|
|
34
|
+
} & DeclaredFields<S, U, never, Is, Ix> & MailerField<M> & KicksField<Ks> & SignsField<Sg, Purposes> & JobEgressField;
|
|
35
35
|
/** The mail transport a job reaches, inferred from its optional field and then required, like a declared field. */
|
|
36
36
|
type MailerField<M> = {
|
|
37
37
|
/** `'installation'`: the installation's mail transport; `'own'`: the application's own. */
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import type { SpaAssets } from './implement/dispatch.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Serves each SPA mount from its build directory under `root`; a page route that names no file gets the SPA's
|
|
4
|
+
* `index.html` (`isPageRoute`), and a missing asset is 404.
|
|
5
|
+
*/
|
|
3
6
|
export declare function diskAssets(root: string): SpaAssets;
|
|
@@ -9,6 +9,7 @@ import { type ContractJobs, type HandlerErrorSink } from '../jobs/runner';
|
|
|
9
9
|
import type { ChallengeFetch, ChallengeWork } from './challenge';
|
|
10
10
|
import { type ConnectFetch } from './connect';
|
|
11
11
|
import { type SpaAssets } from './dispatch';
|
|
12
|
+
import { type HostResolver } from './egress';
|
|
12
13
|
import type { CapturedEnvironment } from './environment';
|
|
13
14
|
import type { ContractImplementation } from './implement';
|
|
14
15
|
import { type WorkloadTokenSource } from './workload';
|
|
@@ -57,6 +58,11 @@ export interface BootOptions {
|
|
|
57
58
|
* with a job or a webhook does not boot.
|
|
58
59
|
*/
|
|
59
60
|
readonly workloadTokens?: WorkloadTokenSource;
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a host name to its addresses, which the Bun adapter supplies (`node:dns`): a cron job's public egress then
|
|
63
|
+
* refuses a public name of a private address before each hop. A Worker exposes no resolution and passes none.
|
|
64
|
+
*/
|
|
65
|
+
readonly resolveHost?: HostResolver;
|
|
60
66
|
/** What posts a report to Operations' ingest; `fetch` by default. */
|
|
61
67
|
readonly operationsFetch?: (request: Request) => Promise<Response>;
|
|
62
68
|
/** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
|
|
@@ -11,6 +11,8 @@ export declare const CAPABILITY_REFUSALS: {
|
|
|
11
11
|
readonly undeclared_binding: "the site named a webhook it does not declare in binds";
|
|
12
12
|
readonly undeclared_signs: "the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows";
|
|
13
13
|
readonly undeclared_egress: "the site called an origin it does not declare in egress";
|
|
14
|
+
readonly refused_egress: "the site's public egress refused the host: an IP address, a local or internal name, a non-default port, a host of this installation, or a name that resolves to a private address";
|
|
15
|
+
readonly oversized_egress: "a response to the site's public egress was larger than it reads";
|
|
14
16
|
readonly wrong_binding: "a webhook binding was handed a capability of another kind or dimension than the webhook is bound to";
|
|
15
17
|
readonly missing_id: "the job named an object without an id";
|
|
16
18
|
readonly wrong_capability: "an operation was handed a capability of another dimension or action than it needs";
|
|
@@ -11,6 +11,7 @@ import type { BrowserConfig } from './browser-config';
|
|
|
11
11
|
import { type CapabilityMint } from './capability';
|
|
12
12
|
import type { ChallengeIssuer } from './challenge';
|
|
13
13
|
import { type ImportWire } from './connect';
|
|
14
|
+
import { type SiteEgress } from './egress';
|
|
14
15
|
import type { FailureReports } from './failures';
|
|
15
16
|
import { type HeaderPolicy } from './headers';
|
|
16
17
|
import type { ContractDatabase } from './implement';
|
|
@@ -66,7 +67,7 @@ export type ServedEndpoint = {
|
|
|
66
67
|
/** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
|
|
67
68
|
readonly site?: ServedSite;
|
|
68
69
|
/** The origins a staged-kit site's `fetch` reaches while its handler runs; absent for a handler `implement` bound. */
|
|
69
|
-
readonly egress?:
|
|
70
|
+
readonly egress?: SiteEgress;
|
|
70
71
|
/** Its buckets and time limit; without them (a generated endpoint) no bucket and the request budget. */
|
|
71
72
|
readonly admission?: ProcedureAdmission;
|
|
72
73
|
/** A POST's `query`, parsed from the URL beside the body: its schema and its shape. */
|
|
@@ -1,5 +1,40 @@
|
|
|
1
|
+
import { type DeclaredEgress } from '@fabrika/auth-core';
|
|
2
|
+
/** The addresses a host name resolves to, where the runtime exposes resolution: Bun's `node:dns`, never a Worker. */
|
|
3
|
+
export type HostResolver = (hostname: string) => Promise<readonly string[]>;
|
|
4
|
+
/** What public egress refuses beyond what a URL alone shows (`publicEgressRefusal`). */
|
|
5
|
+
export interface PublicEgressRules {
|
|
6
|
+
/** The installation's own hosts, normalized: every host a framework variable names, and `FABRIKA_EGRESS_REFUSED_HOSTS`. */
|
|
7
|
+
readonly refusedHosts: ReadonlySet<string>;
|
|
8
|
+
/** Resolves each hop's host before it is sent, so a public name of a private address is refused; `null` on Cloudflare. */
|
|
9
|
+
readonly resolve: HostResolver | null;
|
|
10
|
+
}
|
|
11
|
+
/** What one site's `fetch` reaches: its declared origins, or the rules of public egress. */
|
|
12
|
+
export type SiteEgress = readonly string[] | PublicEgressRules;
|
|
13
|
+
/**
|
|
14
|
+
* The bounds of one request under public egress, the redirects it follows included. The handler's own signal still
|
|
15
|
+
* aborts it sooner (`ctx.signal`, an `AbortSignal.timeout` of its own).
|
|
16
|
+
*/
|
|
17
|
+
export declare const PUBLIC_EGRESS_LIMITS: {
|
|
18
|
+
/** One request, its redirects and the reading of its body included: the abort also stops a body still streaming. */
|
|
19
|
+
readonly timeoutMs: 60000;
|
|
20
|
+
/** One response's body as the handler reads it; a larger one is refused, by its `Content-Length` or while it streams. */
|
|
21
|
+
readonly maxResponseBytes: number;
|
|
22
|
+
/** The redirects one request follows when its `redirect` mode is `follow`, each hop checked again. */
|
|
23
|
+
readonly maxRedirects: 10;
|
|
24
|
+
};
|
|
1
25
|
/** Runs a site's handler: every `fetch` it starts reaches only `egress`. */
|
|
2
|
-
export declare function runAsSite<T>(site: string, egress:
|
|
26
|
+
export declare function runAsSite<T>(site: string, egress: SiteEgress, work: () => Promise<T>): Promise<T>;
|
|
27
|
+
/**
|
|
28
|
+
* The egress a site runs with: its declared origins, or `rules` for `'public'`. Only a cron job's runner holds rules; a
|
|
29
|
+
* site run without them reaches nothing, though the build lets no other site declare `'public'`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function siteEgress(declared: DeclaredEgress, rules: PublicEgressRules | null): SiteEgress;
|
|
32
|
+
/**
|
|
33
|
+
* The rules of public egress in one environment: the hosts its framework variables name (IAM, Operations, its own
|
|
34
|
+
* origin, the platform's receivers, the object store), the installation hosts Control hands it, and the resolver the
|
|
35
|
+
* runtime adapter supplies.
|
|
36
|
+
*/
|
|
37
|
+
export declare function publicEgressRules(framework: Readonly<Record<string, string>>, resolve: HostResolver | null): PublicEgressRules;
|
|
3
38
|
/**
|
|
4
39
|
* `facility` with each of its methods run outside any site's scope: a framework facility a handler calls, whose own
|
|
5
40
|
* requests are the platform's, not the site's.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ContractArtifact, type JobMailer } from '@fabrika/auth-core';
|
|
1
|
+
import { type ContractArtifact, type DeclaredEgress, type JobMailer } from '@fabrika/auth-core';
|
|
2
2
|
import { type DataAccess, type DataHandles, type FileBinding, type Session, type SqlTag } from '@fabrika/db/framework';
|
|
3
3
|
import type { DataKind, UnscopedMode } from '@fabrika/db/tables';
|
|
4
4
|
import type { SqlMode } from '../contract/context';
|
|
@@ -16,8 +16,8 @@ export interface SiteDeclaration {
|
|
|
16
16
|
readonly binds: string | null;
|
|
17
17
|
/** The token purposes `ctx.tokens.sign` may sign. */
|
|
18
18
|
readonly signs: readonly string[];
|
|
19
|
-
/** The HTTPS origins the global `fetch` reaches while the site's handler runs. */
|
|
20
|
-
readonly egress:
|
|
19
|
+
/** The HTTPS origins the global `fetch` reaches while the site's handler runs, or `'public'`: every public host (a cron job). */
|
|
20
|
+
readonly egress: DeclaredEgress;
|
|
21
21
|
}
|
|
22
22
|
/** A public procedure's reach: the texts the verified signed tokens of its request carry, which name the rows it changes. */
|
|
23
23
|
export interface PublicReach {
|
|
@@ -4,6 +4,7 @@ import type { CapabilityAuthority } from '@fabrika/db/capability';
|
|
|
4
4
|
import { type DataAccess, type FrameworkTables } from '@fabrika/db/framework';
|
|
5
5
|
import { type StoredFileKinds } from '../files/kinds';
|
|
6
6
|
import { type CapabilityMint } from '../implement/capability';
|
|
7
|
+
import { type PublicEgressRules } from '../implement/egress';
|
|
7
8
|
import type { ContractDatabase } from '../implement/implement';
|
|
8
9
|
import type { SignedTokens } from '../implement/tokens';
|
|
9
10
|
import { type AppUrl } from '../implement/urls';
|
|
@@ -130,6 +131,8 @@ export interface JobBindingInput {
|
|
|
130
131
|
readonly tokens?: SignedTokens;
|
|
131
132
|
/** `ctx.url`: absolute links to the application's own procedures; without it every link is refused. */
|
|
132
133
|
readonly url?: AppUrl;
|
|
134
|
+
/** The rules of public egress, which `boot` reads from the environment; without them a public job reaches nothing. */
|
|
135
|
+
readonly publicEgress?: PublicEgressRules;
|
|
133
136
|
}
|
|
134
137
|
export interface BoundJobs {
|
|
135
138
|
readonly jobs: ContractJobs;
|
|
@@ -78,6 +78,30 @@ export interface EndpointAccessKit {
|
|
|
78
78
|
*/
|
|
79
79
|
anyone(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>;
|
|
80
80
|
}
|
|
81
|
+
export interface EndpointAccessOptions {
|
|
82
|
+
/**
|
|
83
|
+
* The file every case of an endpoint sends in a file field instead of the kit's sample, by endpoint id and input
|
|
84
|
+
* field: a file of the application's repository that a handler inspecting the content accepts (a valid package). Its
|
|
85
|
+
* type is the first of the field's kind that its bytes are; a file of none of them throws. `files` of the fixtures.
|
|
86
|
+
*/
|
|
87
|
+
readonly files?: Readonly<Record<string, Readonly<Record<string, URL>>>>;
|
|
88
|
+
/**
|
|
89
|
+
* The error types of the refusals (400) an endpoint's handler answers its own object with when the case's input meets
|
|
90
|
+
* no domain rule the kit can satisfy, by endpoint id: such a 400 is the handler's own answer, so the request passed
|
|
91
|
+
* the access form, and the endpoint's own case gets it as `{ status: 400 }` instead of failing; so does the other
|
|
92
|
+
* scope's request a leak case makes first. The own scope's answer of a leak case, and a global or capability case,
|
|
93
|
+
* still throw on it (an answer that holds nothing proves no isolation), so does any other 400, and an input the schema
|
|
94
|
+
* refuses (`validation`) always does. `refusals` of the fixtures.
|
|
95
|
+
*/
|
|
96
|
+
readonly refusals?: Readonly<Record<string, readonly string[]>>;
|
|
97
|
+
/**
|
|
98
|
+
* Known values for text columns of the row the endpoint addresses (the row of the table that owns its dimension), by
|
|
99
|
+
* endpoint id: the value in the own scope's row, and `<value>-other` in the other scope's, which counts among the
|
|
100
|
+
* other scope's values a leak shows. For a handler whose rule ties a column to the input or a file fixture (a package
|
|
101
|
+
* that names the skill's slug). `pins` of the fixtures.
|
|
102
|
+
*/
|
|
103
|
+
readonly pins?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
104
|
+
}
|
|
81
105
|
export type Grant = {
|
|
82
106
|
readonly action: string;
|
|
83
107
|
readonly scope: {
|
|
@@ -104,4 +128,4 @@ export declare const managerGrants: (artifact: Pick<ContractArtifact, "roles">,
|
|
|
104
128
|
* The endpoint access kit of `app`, whose data layer is `scoped(tables)`, over `postgres`. It boots `app` once, so the
|
|
105
129
|
* application's layer is bound here and nowhere else in the process.
|
|
106
130
|
*/
|
|
107
|
-
export declare function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase): EndpointAccessKit;
|
|
131
|
+
export declare function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase, options?: EndpointAccessOptions): EndpointAccessKit;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { type DeniedOutcome, type EndpointAccessKit, endpointAccessKit, type LeakOutcome, type ReachOutcome } from './endpoint-access';
|
|
1
|
+
export { type DeniedOutcome, type EndpointAccessKit, endpointAccessKit, type EndpointAccessOptions, type LeakOutcome, type ReachOutcome, } from './endpoint-access';
|
|
2
2
|
export { type AppCaller, type AppCountOutcome, type AppIsolationKit, appPolicyIsolationKit, type ChildIsolationRow, type CountOutcome, type IsolationOutcome, type IsolationRow, type ListOutcome, type MisuseOutcome, type PolicyIsolationKit, policyIsolationKit, type ReadOutcome, } from './policy-isolation';
|
|
3
3
|
export { afterAll, describe, expect, test } from './runner';
|