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