@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,827 @@
|
|
|
1
|
+
// The policy isolation kit: it proves that the data layer enforces the declared table policies, not that the
|
|
2
|
+
// application uses them. It serves one table through a probe contract over the application's vocabulary, booted by
|
|
3
|
+
// `testKit` on a data layer of its own: every request takes the production path (the proxy decision, the token, the
|
|
4
|
+
// access form, the request-bound capability, the scope predicate) and only the handler is the kit's, so the
|
|
5
|
+
// application's handlers and stores never run. Each attempt seeds two fresh rows that share no scope value and asks as
|
|
6
|
+
// a caller holding every action of the table in the scope of one of them.
|
|
7
|
+
|
|
8
|
+
import { scoped, type Tables } from '@fabrika/db'
|
|
9
|
+
import { tablePolicies } from '@fabrika/db/tables'
|
|
10
|
+
import { type IsolationWorlds, type SampleValue, tableProbe, type TestDatabase, withoutInboxes } from '@fabrika/db/testing'
|
|
11
|
+
import { z } from 'zod/v4'
|
|
12
|
+
import { createClient } from '../client/contract'
|
|
13
|
+
import { type AnyContract, defineContract, type Vocabulary } from '../contract/define'
|
|
14
|
+
import { implement } from '../implement/implement'
|
|
15
|
+
import type { TestGrant } from './caller'
|
|
16
|
+
import { testKit } from './kit'
|
|
17
|
+
import { refusalStatus, statusOf } from './status'
|
|
18
|
+
|
|
19
|
+
/** Which of the two seeded rows a request names: the caller's own, or the one in the scope it holds nothing in. */
|
|
20
|
+
export type IsolationRow = 'own' | 'other'
|
|
21
|
+
|
|
22
|
+
/** A child row addressed through the caller's own parent with the other row's key: a key from another scope. */
|
|
23
|
+
export type ChildIsolationRow = IsolationRow | 'crossed'
|
|
24
|
+
|
|
25
|
+
export interface IsolationOutcome {
|
|
26
|
+
/** 200 when the operation ran, otherwise the status the caller got. */
|
|
27
|
+
readonly status: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReadOutcome extends IsolationOutcome {
|
|
31
|
+
/** The columns the row came back with. */
|
|
32
|
+
readonly columns: readonly string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ListOutcome extends IsolationOutcome {
|
|
36
|
+
/** Which seeded rows the list reached. */
|
|
37
|
+
readonly rows: readonly IsolationRow[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CountOutcome extends IsolationOutcome {
|
|
41
|
+
readonly count: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AppCountOutcome extends IsolationOutcome {
|
|
45
|
+
/** How many more rows the count answered after the seed than before it. */
|
|
46
|
+
readonly counted: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MisuseOutcome extends IsolationOutcome {
|
|
50
|
+
/** Whether the data layer refused the read capability (`CapabilityError`). */
|
|
51
|
+
readonly refused: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PolicyIsolationKit {
|
|
55
|
+
readonly table: string
|
|
56
|
+
/** The dimension the caller's grants are scoped by. */
|
|
57
|
+
readonly dimension: string
|
|
58
|
+
get(row: ChildIsolationRow): Promise<ReadOutcome>
|
|
59
|
+
/** Lists through a `Within` of the caller's scope. */
|
|
60
|
+
list(): Promise<ListOutcome>
|
|
61
|
+
/** A child table listed through the parent's `Ref`; the dimension table has no parent. */
|
|
62
|
+
listByParent(row: IsolationRow): Promise<ListOutcome>
|
|
63
|
+
count(): Promise<CountOutcome>
|
|
64
|
+
insert(into: IsolationRow): Promise<IsolationOutcome>
|
|
65
|
+
update(row: ChildIsolationRow): Promise<IsolationOutcome>
|
|
66
|
+
remove(row: ChildIsolationRow): Promise<IsolationOutcome>
|
|
67
|
+
/** A `Ref` for the read action handed to update: the data layer must refuse it. */
|
|
68
|
+
updateWithRead(): Promise<MisuseOutcome>
|
|
69
|
+
/** A `Ref` for the read action handed to remove. */
|
|
70
|
+
removeWithRead(): Promise<MisuseOutcome>
|
|
71
|
+
/** Lists from an `app` endpoint of `action`, one of the table's `appRead`, for a global holder: every row. */
|
|
72
|
+
appList(action: string): Promise<ListOutcome>
|
|
73
|
+
/**
|
|
74
|
+
* Counts from an `app` endpoint of `action`, one of the table's `appCount`, for a global holder, before and after
|
|
75
|
+
* two rows of two scopes are seeded: `counted` is 2 when the count reaches every row.
|
|
76
|
+
*/
|
|
77
|
+
appCount(action: string): Promise<AppCountOutcome>
|
|
78
|
+
/** Lists from an `app` endpoint of `action`, one of the table's `appCount`: the data layer must refuse it. */
|
|
79
|
+
appListWithCount(action: string): Promise<MisuseOutcome>
|
|
80
|
+
/**
|
|
81
|
+
* Lists from an `app` endpoint of `action`, one of the table's `appRead`: `leaked` names each answered column that
|
|
82
|
+
* declares another read action, `missing` each column that declares exactly `action` and was not answered.
|
|
83
|
+
*/
|
|
84
|
+
appColumns(action: string): Promise<AppColumnsOutcome>
|
|
85
|
+
/**
|
|
86
|
+
* Groups by the key and every column from an `app` endpoint of `action`, one of the table's `appCount`: a group is a
|
|
87
|
+
* row's values, so the data layer must refuse it.
|
|
88
|
+
*/
|
|
89
|
+
appGroupWithCount(action: string): Promise<MisuseOutcome>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AppColumnsOutcome extends IsolationOutcome {
|
|
93
|
+
readonly leaked: readonly string[]
|
|
94
|
+
readonly missing: readonly string[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* An application table's isolation kit: the rows belong to no scope, so the boundary is the grant. `global` holds the
|
|
99
|
+
* table's actions as global grants; `scoped` holds the same actions in one value of a dimension only, which the proxy
|
|
100
|
+
* refuses on an `app` endpoint. Each attempt seeds one fresh row.
|
|
101
|
+
*/
|
|
102
|
+
export interface AppIsolationKit {
|
|
103
|
+
readonly table: string
|
|
104
|
+
get(caller: AppCaller): Promise<ReadOutcome>
|
|
105
|
+
/** Lists through the `app` capability; `rows` is `['own']` when the list holds the seeded row. */
|
|
106
|
+
list(caller: AppCaller): Promise<ListOutcome>
|
|
107
|
+
insert(caller: AppCaller): Promise<IsolationOutcome>
|
|
108
|
+
update(caller: AppCaller): Promise<IsolationOutcome>
|
|
109
|
+
remove(caller: AppCaller): Promise<IsolationOutcome>
|
|
110
|
+
/** The capability of the read action handed to update: the data layer must refuse it. */
|
|
111
|
+
updateWithRead(): Promise<MisuseOutcome>
|
|
112
|
+
/** The capability of the read action handed to remove. */
|
|
113
|
+
removeWithRead(): Promise<MisuseOutcome>
|
|
114
|
+
/**
|
|
115
|
+
* Lists from an `over` endpoint of the table's read action, for a caller who holds it in one scope only: a scoped
|
|
116
|
+
* site reads every row of an application table by its read action.
|
|
117
|
+
*/
|
|
118
|
+
scopedList(): Promise<ListOutcome>
|
|
119
|
+
/** The same list from an `over` endpoint of another action the table names: the data layer must refuse it. */
|
|
120
|
+
scopedListWithOther(): Promise<MisuseOutcome>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type AppCaller = 'global' | 'scoped'
|
|
124
|
+
|
|
125
|
+
const ById = z.strictObject({ id: z.string() })
|
|
126
|
+
const Tagged = z.strictObject({ tag: z.string() })
|
|
127
|
+
const Edit = z.strictObject({ id: z.string(), tag: z.string() })
|
|
128
|
+
const InScope = z.strictObject({ scope: z.string(), tag: z.string() })
|
|
129
|
+
const ChildByKey = z.strictObject({ parent: z.string(), key: z.string() })
|
|
130
|
+
const ChildEdit = z.strictObject({ parent: z.string(), key: z.string(), tag: z.string() })
|
|
131
|
+
const ChildInsert = z.strictObject({ parent: z.string(), tag: z.string() })
|
|
132
|
+
const ByParent = z.strictObject({ parent: z.string() })
|
|
133
|
+
const Columns = z.strictObject({ columns: z.array(z.string()) })
|
|
134
|
+
const Keys = z.strictObject({ keys: z.array(z.string()) })
|
|
135
|
+
const Count = z.strictObject({ count: z.string() })
|
|
136
|
+
const Done = z.strictObject({ done: z.boolean() })
|
|
137
|
+
const Refused = z.strictObject({ refused: z.boolean() })
|
|
138
|
+
|
|
139
|
+
interface ProbeActions {
|
|
140
|
+
readonly read: string
|
|
141
|
+
readonly insert: string | null
|
|
142
|
+
readonly update: string | null
|
|
143
|
+
readonly delete: string | null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isCapabilityError(error: unknown): boolean {
|
|
147
|
+
return error instanceof Error && error.name === 'CapabilityError'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function attempt<T extends object>(run: () => Promise<T>, failed: (status: number) => T): Promise<T> {
|
|
151
|
+
try {
|
|
152
|
+
return await run()
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return failed(refusalStatus(error))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A write's outcome: 200 when it ran, the status otherwise. */
|
|
159
|
+
async function wrote(run: () => Promise<unknown>): Promise<IsolationOutcome> {
|
|
160
|
+
return { status: await statusOf(run()) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function dimensionProbe(vocabulary: Vocabulary<string, string, string>, dimension: string, list: string, actions: ProbeActions) {
|
|
164
|
+
const { read } = actions
|
|
165
|
+
return defineContract(vocabulary, (c) => ({
|
|
166
|
+
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
167
|
+
get: c.query({ input: ById, output: Columns, access: { action: read, on: dimension, from: 'id' } }),
|
|
168
|
+
list: c.query({ output: Keys, access: { action: read, over: list } }),
|
|
169
|
+
count: c.query({ output: Count, access: { action: read, over: list } }),
|
|
170
|
+
insert: c.mutation({ input: InScope, output: Done, access: { action: actions.insert ?? read, in: list, from: 'scope' } }),
|
|
171
|
+
update: c.mutation({ input: Edit, output: Done, access: { action: actions.update ?? read, on: dimension, from: 'id' } }),
|
|
172
|
+
remove: c.mutation({ input: ById, output: Done, access: { action: actions.delete ?? read, on: dimension, from: 'id' } }),
|
|
173
|
+
updateWithRead: c.mutation({ input: Edit, output: Refused, access: { action: read, on: dimension, from: 'id' } }),
|
|
174
|
+
removeWithRead: c.mutation({ input: ById, output: Refused, access: { action: read, on: dimension, from: 'id' } }),
|
|
175
|
+
}),
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function childProbe(vocabulary: Vocabulary<string, string, string>, parent: string, list: string, actions: ProbeActions) {
|
|
180
|
+
const { read } = actions
|
|
181
|
+
return defineContract(vocabulary, (c) => ({
|
|
182
|
+
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
183
|
+
get: c.query({ input: ChildByKey, output: Columns, access: { action: read, on: parent, from: 'parent' } }),
|
|
184
|
+
list: c.query({ output: Keys, access: { action: read, over: list } }),
|
|
185
|
+
listByParent: c.query({ input: ByParent, output: Keys, access: { action: read, on: parent, from: 'parent' } }),
|
|
186
|
+
count: c.query({ output: Count, access: { action: read, over: list } }),
|
|
187
|
+
insert: c.mutation({ input: ChildInsert, output: Done, access: { action: actions.insert ?? read, in: parent, from: 'parent' } }),
|
|
188
|
+
update: c.mutation({ input: ChildEdit, output: Done, access: { action: actions.update ?? read, on: parent, from: 'parent' } }),
|
|
189
|
+
remove: c.mutation({ input: ChildByKey, output: Done, access: { action: actions.delete ?? read, on: parent, from: 'parent' } }),
|
|
190
|
+
updateWithRead: c.mutation({ input: ChildEdit, output: Refused, access: { action: read, on: parent, from: 'parent' } }),
|
|
191
|
+
removeWithRead: c.mutation({ input: ChildByKey, output: Refused, access: { action: read, on: parent, from: 'parent' } }),
|
|
192
|
+
}),
|
|
193
|
+
}))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** An action the table names besides its read action, or `null` when it names none. */
|
|
197
|
+
function otherAction(actions: ProbeActions): string | null {
|
|
198
|
+
return [actions.insert, actions.update, actions.delete].find((action) => action !== null && action !== actions.read) ?? null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function appProbe(vocabulary: Vocabulary<string, string, string>, actions: ProbeActions) {
|
|
202
|
+
const { read } = actions
|
|
203
|
+
return defineContract(vocabulary, (c) => ({
|
|
204
|
+
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
205
|
+
get: c.query({ input: ById, output: Columns, access: { action: read, app: true } }),
|
|
206
|
+
list: c.query({ output: Keys, access: { action: read, app: true } }),
|
|
207
|
+
insert: c.mutation({ input: Tagged, output: Done, access: { action: actions.insert ?? read, app: true } }),
|
|
208
|
+
update: c.mutation({ input: Edit, output: Done, access: { action: actions.update ?? read, app: true } }),
|
|
209
|
+
remove: c.mutation({ input: ById, output: Done, access: { action: actions.delete ?? read, app: true } }),
|
|
210
|
+
updateWithRead: c.mutation({ input: Edit, output: Refused, access: { action: read, app: true } }),
|
|
211
|
+
removeWithRead: c.mutation({ input: ById, output: Refused, access: { action: read, app: true } }),
|
|
212
|
+
}),
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Scoped sites over an application table: `over` the vocabulary's first dimension, of its read action and of another. */
|
|
217
|
+
function scopedAppProbe(vocabulary: Vocabulary<string, string, string>, actions: ProbeActions, dimension: string) {
|
|
218
|
+
return defineContract(vocabulary, (c) => ({
|
|
219
|
+
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
220
|
+
list: c.query({ output: Keys, access: { action: actions.read, over: dimension } }),
|
|
221
|
+
listWithOther: c.query({ output: Refused, access: { action: otherAction(actions) ?? actions.read, over: dimension } }),
|
|
222
|
+
}),
|
|
223
|
+
}))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The isolation kit of `table`, an application table of `tables`, over `postgres`, through a probe contract of `app`
|
|
228
|
+
* endpoints. `contract` gives only the vocabulary. The kit binds a data layer of its own.
|
|
229
|
+
*/
|
|
230
|
+
export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
|
|
231
|
+
contract: C,
|
|
232
|
+
tables: Tables<DB, P>,
|
|
233
|
+
table: string,
|
|
234
|
+
postgres: TestDatabase,
|
|
235
|
+
): AppIsolationKit {
|
|
236
|
+
const policy = tablePolicies(tables)[table]
|
|
237
|
+
if (policy === undefined) {
|
|
238
|
+
throw new Error(`${table} is not a table of src/db/tables.ts`)
|
|
239
|
+
}
|
|
240
|
+
const scope = policy.scope
|
|
241
|
+
if (scope.mode !== 'app') {
|
|
242
|
+
throw new Error(`${table} is a ${scope.mode} table; policyIsolationKit probes a dimension or child table`)
|
|
243
|
+
}
|
|
244
|
+
const actions: ProbeActions = policy.actions
|
|
245
|
+
const layer = scoped(withoutInboxes(tables))
|
|
246
|
+
const probe = tableProbe(layer, table)
|
|
247
|
+
const expectVersion = policy.version === null ? undefined : 1
|
|
248
|
+
const staged = new Map<string, Readonly<Record<string, SampleValue>>>()
|
|
249
|
+
const tagged = (values: Readonly<Record<string, SampleValue>>): string => {
|
|
250
|
+
const tag = crypto.randomUUID()
|
|
251
|
+
staged.set(tag, values)
|
|
252
|
+
return tag
|
|
253
|
+
}
|
|
254
|
+
const valuesOf = (tag: string): Readonly<Record<string, SampleValue>> => {
|
|
255
|
+
const values = staged.get(tag)
|
|
256
|
+
staged.delete(tag)
|
|
257
|
+
if (values === undefined) {
|
|
258
|
+
throw new Error(`no values were staged for ${tag}`)
|
|
259
|
+
}
|
|
260
|
+
return values
|
|
261
|
+
}
|
|
262
|
+
const required = (operation: 'insert' | 'update' | 'delete'): void => {
|
|
263
|
+
if (actions[operation] === null) {
|
|
264
|
+
throw new Error(`the policy of ${table} declares no ${operation} action`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const misuse = async (run: () => Promise<unknown>): Promise<{ readonly refused: boolean }> => {
|
|
268
|
+
try {
|
|
269
|
+
await run()
|
|
270
|
+
return { refused: false }
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (isCapabilityError(error)) {
|
|
273
|
+
return { refused: true }
|
|
274
|
+
}
|
|
275
|
+
throw error
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const probeContract = appProbe(contract.vocabulary, actions)
|
|
279
|
+
const kit = testKit(
|
|
280
|
+
implement(probeContract, {
|
|
281
|
+
database: layer,
|
|
282
|
+
deps: () => ({}),
|
|
283
|
+
handlers: () => ({
|
|
284
|
+
probe: {
|
|
285
|
+
get: async ({ input, ctx }) => ({ columns: Object.keys(await probe.get(ctx.app, input.id)) }),
|
|
286
|
+
list: async ({ ctx }) => ({ keys: (await probe.select(ctx.app)).map((row) => String(row[scope.key])) }),
|
|
287
|
+
insert: async ({ input, ctx }) => {
|
|
288
|
+
await probe.insert(ctx.app, valuesOf(input.tag))
|
|
289
|
+
return { done: true }
|
|
290
|
+
},
|
|
291
|
+
update: async ({ input, ctx }) => {
|
|
292
|
+
await probe.update(ctx.app, input.id, valuesOf(input.tag), expectVersion)
|
|
293
|
+
return { done: true }
|
|
294
|
+
},
|
|
295
|
+
remove: async ({ input, ctx }) => {
|
|
296
|
+
await probe.remove(ctx.app, input.id)
|
|
297
|
+
return { done: true }
|
|
298
|
+
},
|
|
299
|
+
updateWithRead: ({ input, ctx }) => misuse(() => probe.update(ctx.app, input.id, valuesOf(input.tag), expectVersion)),
|
|
300
|
+
removeWithRead: ({ input, ctx }) => misuse(() => probe.remove(ctx.app, input.id)),
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
}),
|
|
304
|
+
{ database: postgres.database },
|
|
305
|
+
)
|
|
306
|
+
const held = [...new Set([actions.read, actions.insert, actions.update, actions.delete].filter((action) => action !== null))]
|
|
307
|
+
const dimension = scopeTypeOf(contract.vocabulary.dimensions)
|
|
308
|
+
let scopedSites: ReturnType<typeof scopedAppKit> | undefined
|
|
309
|
+
const inOneScope = (action: string) => {
|
|
310
|
+
if (!Object.hasOwn(contract.vocabulary.dimensions, dimension)) {
|
|
311
|
+
throw new Error(`the vocabulary declares no dimension, so no scoped site reads ${table}`)
|
|
312
|
+
}
|
|
313
|
+
scopedSites ??= scopedAppKit(scopedAppProbe(contract.vocabulary, actions, dimension), tables, table, scope.key, postgres)
|
|
314
|
+
return scopedSites.as(action, dimension)
|
|
315
|
+
}
|
|
316
|
+
const grantsOf = (caller: AppCaller): TestGrant<AnyContract>[] =>
|
|
317
|
+
caller === 'global'
|
|
318
|
+
? held.map((action) => ({ action }))
|
|
319
|
+
: held.map((action) => ({ action, scope: { type: dimension, value: `${dimension}-scoped-${crypto.randomUUID()}` } }))
|
|
320
|
+
const session = (caller: AppCaller) => createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller(grantsOf(caller))) })
|
|
321
|
+
const seed = () => postgres.seedRow(tables, table)
|
|
322
|
+
const appKit: AppIsolationKit = {
|
|
323
|
+
table,
|
|
324
|
+
async get(caller) {
|
|
325
|
+
const row = await seed()
|
|
326
|
+
const api = session(caller)
|
|
327
|
+
return attempt(async () => ({ status: 200, ...(await api.get({ id: row.key })) }), (status) => ({ status, columns: [] }))
|
|
328
|
+
},
|
|
329
|
+
async list(caller) {
|
|
330
|
+
const row = await seed()
|
|
331
|
+
const api = session(caller)
|
|
332
|
+
return attempt(async () => {
|
|
333
|
+
const { keys } = await api.list()
|
|
334
|
+
const rows: IsolationRow[] = keys.includes(row.key) ? ['own'] : []
|
|
335
|
+
return { status: 200, rows }
|
|
336
|
+
}, (status) => ({ status, rows: [] }))
|
|
337
|
+
},
|
|
338
|
+
async insert(caller) {
|
|
339
|
+
required('insert')
|
|
340
|
+
const api = session(caller)
|
|
341
|
+
const tag = tagged(await postgres.sampleInsert(tables, table))
|
|
342
|
+
return wrote(() => api.insert({ tag }))
|
|
343
|
+
},
|
|
344
|
+
async update(caller) {
|
|
345
|
+
required('update')
|
|
346
|
+
const row = await seed()
|
|
347
|
+
const api = session(caller)
|
|
348
|
+
return wrote(() => api.update({ id: row.key, tag: tagged(row.updates) }))
|
|
349
|
+
},
|
|
350
|
+
async remove(caller) {
|
|
351
|
+
required('delete')
|
|
352
|
+
const row = await seed()
|
|
353
|
+
const api = session(caller)
|
|
354
|
+
return wrote(() => api.remove({ id: row.key }))
|
|
355
|
+
},
|
|
356
|
+
async updateWithRead() {
|
|
357
|
+
const row = await seed()
|
|
358
|
+
const api = session('global')
|
|
359
|
+
return attempt(
|
|
360
|
+
async () => ({ status: 200, ...(await api.updateWithRead({ id: row.key, tag: tagged(row.updates) })) }),
|
|
361
|
+
(status) => ({ status, refused: false }),
|
|
362
|
+
)
|
|
363
|
+
},
|
|
364
|
+
async removeWithRead() {
|
|
365
|
+
const row = await seed()
|
|
366
|
+
const api = session('global')
|
|
367
|
+
return attempt(async () => ({ status: 200, ...(await api.removeWithRead({ id: row.key })) }), (status) => ({ status, refused: false }))
|
|
368
|
+
},
|
|
369
|
+
async scopedList() {
|
|
370
|
+
const row = await seed()
|
|
371
|
+
const api = inOneScope(actions.read)
|
|
372
|
+
return attempt(async () => {
|
|
373
|
+
const { keys } = await api.list()
|
|
374
|
+
const rows: IsolationRow[] = keys.includes(row.key) ? ['own'] : []
|
|
375
|
+
return { status: 200, rows }
|
|
376
|
+
}, (status) => ({ status, rows: [] }))
|
|
377
|
+
},
|
|
378
|
+
async scopedListWithOther() {
|
|
379
|
+
const other = otherAction(actions)
|
|
380
|
+
if (other === null) {
|
|
381
|
+
throw new Error(`the policy of ${table} names no action besides its read action`)
|
|
382
|
+
}
|
|
383
|
+
await seed()
|
|
384
|
+
const api = inOneScope(other)
|
|
385
|
+
return attempt(async () => ({ status: 200, ...(await api.listWithOther()) }), (status) => ({ status, refused: false }))
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
return Object.freeze(appKit)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** `app` endpoints of one action over a dimension or child table: what its `appRead` and `appCount` reach. */
|
|
392
|
+
function appReachProbe(vocabulary: Vocabulary<string, string, string>, action: string) {
|
|
393
|
+
return defineContract(vocabulary, (c) => ({
|
|
394
|
+
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
395
|
+
list: c.query({ output: Keys, access: { action, app: true } }),
|
|
396
|
+
count: c.query({ output: Count, access: { action, app: true } }),
|
|
397
|
+
listRefused: c.query({ output: Refused, access: { action, app: true } }),
|
|
398
|
+
columns: c.query({ output: Columns, access: { action, app: true } }),
|
|
399
|
+
groupRefused: c.query({ output: Refused, access: { action, app: true } }),
|
|
400
|
+
}),
|
|
401
|
+
}))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The `app` reach of a dimension or child table, one kit per action on a data layer of its own (a layer binds once),
|
|
406
|
+
* each asked by a global holder of the action. `seed` writes the two rows of two scopes the lists must both reach.
|
|
407
|
+
*/
|
|
408
|
+
function appReachKit<DB, P>(
|
|
409
|
+
contract: AnyContract,
|
|
410
|
+
tables: Tables<DB, P>,
|
|
411
|
+
table: string,
|
|
412
|
+
key: string,
|
|
413
|
+
postgres: TestDatabase,
|
|
414
|
+
seed: () => Promise<IsolationWorlds>,
|
|
415
|
+
) {
|
|
416
|
+
const policy = tablePolicies(tables)[table]
|
|
417
|
+
const columnReads: Readonly<Record<string, string>> = Object.fromEntries(
|
|
418
|
+
Object.entries(policy?.columns ?? {}).flatMap(([column, declared]) => (declared.read === undefined ? [] : [[column, declared.read]])),
|
|
419
|
+
)
|
|
420
|
+
const refusedBy = async (run: () => Promise<unknown>): Promise<{ readonly refused: boolean }> => {
|
|
421
|
+
try {
|
|
422
|
+
await run()
|
|
423
|
+
return { refused: false }
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (isCapabilityError(error)) {
|
|
426
|
+
return { refused: true }
|
|
427
|
+
}
|
|
428
|
+
throw error
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const kits = new Map<string, ReturnType<typeof appReachClient>>()
|
|
432
|
+
const appReachClient = (action: string) => {
|
|
433
|
+
const probeContract = appReachProbe(contract.vocabulary, action)
|
|
434
|
+
const layer = scoped(withoutInboxes(tables))
|
|
435
|
+
const probe = tableProbe(layer, table)
|
|
436
|
+
const kit = testKit(
|
|
437
|
+
implement(probeContract, {
|
|
438
|
+
database: layer,
|
|
439
|
+
deps: () => ({}),
|
|
440
|
+
handlers: () => ({
|
|
441
|
+
probe: {
|
|
442
|
+
list: async ({ ctx }) => ({ keys: (await probe.select(ctx.app)).map((row) => String(row[key])) }),
|
|
443
|
+
count: async ({ ctx }) => ({ count: await probe.count(ctx.app) }),
|
|
444
|
+
listRefused: ({ ctx }) => refusedBy(() => probe.select(ctx.app)),
|
|
445
|
+
columns: async ({ ctx }) => ({ columns: [...new Set((await probe.select(ctx.app)).flatMap((row) => Object.keys(row)))] }),
|
|
446
|
+
groupRefused: ({ ctx }) => refusedBy(() => probe.group(ctx.app, [...new Set([key, ...(policy?.insert ?? [])])])),
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
}),
|
|
450
|
+
{ database: postgres.database },
|
|
451
|
+
)
|
|
452
|
+
return createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller([{ action }])) })
|
|
453
|
+
}
|
|
454
|
+
const client = (action: string) => {
|
|
455
|
+
const known = kits.get(action)
|
|
456
|
+
if (known !== undefined) return known
|
|
457
|
+
const made = appReachClient(action)
|
|
458
|
+
kits.set(action, made)
|
|
459
|
+
return made
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
async appList(action: string): Promise<ListOutcome> {
|
|
463
|
+
const worlds = await seed()
|
|
464
|
+
return attempt(async () => ({ status: 200, rows: reached(worlds, (await client(action).list()).keys) }), (status) => ({ status, rows: [] }))
|
|
465
|
+
},
|
|
466
|
+
async appCount(action: string): Promise<AppCountOutcome> {
|
|
467
|
+
const api = client(action)
|
|
468
|
+
return attempt(async () => {
|
|
469
|
+
const before = Number((await api.count()).count)
|
|
470
|
+
await seed()
|
|
471
|
+
return { status: 200, counted: Number((await api.count()).count) - before }
|
|
472
|
+
}, (status) => ({ status, counted: 0 }))
|
|
473
|
+
},
|
|
474
|
+
async appListWithCount(action: string): Promise<MisuseOutcome> {
|
|
475
|
+
await seed()
|
|
476
|
+
return attempt(async () => ({ status: 200, ...(await client(action).listRefused()) }), (status) => ({ status, refused: false }))
|
|
477
|
+
},
|
|
478
|
+
async appColumns(action: string): Promise<AppColumnsOutcome> {
|
|
479
|
+
await seed()
|
|
480
|
+
return attempt(async () => {
|
|
481
|
+
const answered = new Set((await client(action).columns()).columns)
|
|
482
|
+
const declared = Object.entries(columnReads)
|
|
483
|
+
return {
|
|
484
|
+
status: 200,
|
|
485
|
+
leaked: declared.filter(([column, read]) => read !== action && answered.has(column)).map(([column]) => column),
|
|
486
|
+
missing: declared.filter(([column, read]) => read === action && !answered.has(column)).map(([column]) => column),
|
|
487
|
+
}
|
|
488
|
+
}, (status) => ({ status, leaked: [], missing: [] }))
|
|
489
|
+
},
|
|
490
|
+
async appGroupWithCount(action: string): Promise<MisuseOutcome> {
|
|
491
|
+
await seed()
|
|
492
|
+
return attempt(async () => ({ status: 200, ...(await client(action).groupRefused()) }), (status) => ({ status, refused: false }))
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The kit of `scopedAppProbe`, on a data layer of its own (a layer binds once): its handlers read `table` through the
|
|
499
|
+
* `Within` of each `over` site.
|
|
500
|
+
*/
|
|
501
|
+
function scopedAppKit<DB, P>(
|
|
502
|
+
probeContract: ReturnType<typeof scopedAppProbe>,
|
|
503
|
+
tables: Tables<DB, P>,
|
|
504
|
+
table: string,
|
|
505
|
+
key: string,
|
|
506
|
+
postgres: TestDatabase,
|
|
507
|
+
) {
|
|
508
|
+
const layer = scoped(withoutInboxes(tables))
|
|
509
|
+
const probe = tableProbe(layer, table)
|
|
510
|
+
const kit = testKit(
|
|
511
|
+
implement(probeContract, {
|
|
512
|
+
database: layer,
|
|
513
|
+
deps: () => ({}),
|
|
514
|
+
handlers: () => ({
|
|
515
|
+
probe: {
|
|
516
|
+
list: async ({ ctx }) => ({ keys: (await probe.select(ctx.within)).map((row) => String(row[key])) }),
|
|
517
|
+
listWithOther: async ({ ctx }) => {
|
|
518
|
+
try {
|
|
519
|
+
await probe.select(ctx.within)
|
|
520
|
+
return { refused: false }
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (isCapabilityError(error)) {
|
|
523
|
+
return { refused: true }
|
|
524
|
+
}
|
|
525
|
+
throw error
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
}),
|
|
530
|
+
}),
|
|
531
|
+
{ database: postgres.database },
|
|
532
|
+
)
|
|
533
|
+
return {
|
|
534
|
+
as: (action: string, dimension: string) =>
|
|
535
|
+
createClient(probeContract, 'probe', {
|
|
536
|
+
fetch: kit.browser(kit.testCaller([{ action, scope: { type: dimension, value: `${dimension}-scoped-${crypto.randomUUID()}` } }])),
|
|
537
|
+
}),
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The policy isolation kit of `table`, a dimension or child table of `tables`, over `postgres`. `contract` gives only the
|
|
543
|
+
* vocabulary: its actions, dimensions and roles. The kit binds a data layer of its own, so it does not touch the
|
|
544
|
+
* application's.
|
|
545
|
+
*/
|
|
546
|
+
export function policyIsolationKit<C extends AnyContract, DB, P>(
|
|
547
|
+
contract: C,
|
|
548
|
+
tables: Tables<DB, P>,
|
|
549
|
+
table: string,
|
|
550
|
+
postgres: TestDatabase,
|
|
551
|
+
): PolicyIsolationKit {
|
|
552
|
+
const policy = tablePolicies(tables)[table]
|
|
553
|
+
if (policy === undefined) {
|
|
554
|
+
throw new Error(`${table} is not a table of src/db/tables.ts`)
|
|
555
|
+
}
|
|
556
|
+
const scope = policy.scope
|
|
557
|
+
if (scope.mode !== 'dimension' && scope.mode !== 'child') {
|
|
558
|
+
throw new Error(`${table} is a ${scope.mode} table; no store reaches it, so it has no isolation tests`)
|
|
559
|
+
}
|
|
560
|
+
const actions: ProbeActions = policy.actions
|
|
561
|
+
// The probe serves no form, so it compiles against the policies without the forms' inboxes.
|
|
562
|
+
const layer = scoped(withoutInboxes(tables))
|
|
563
|
+
const probe = tableProbe(layer, table)
|
|
564
|
+
const expectVersion = policy.version === null ? undefined : 1
|
|
565
|
+
const pending = new Map<string, Readonly<Record<string, SampleValue>>>()
|
|
566
|
+
const valuesOf = (tag: string): Readonly<Record<string, SampleValue>> => {
|
|
567
|
+
const values = pending.get(tag)
|
|
568
|
+
pending.delete(tag)
|
|
569
|
+
if (values === undefined) {
|
|
570
|
+
throw new Error(`no values were staged for ${tag}`)
|
|
571
|
+
}
|
|
572
|
+
return values
|
|
573
|
+
}
|
|
574
|
+
const keysOf = (rows: readonly Readonly<Record<string, unknown>>[]): string[] => rows.map((row) => String(row[scope.key]))
|
|
575
|
+
const tagged = (values: Readonly<Record<string, SampleValue>>): string => {
|
|
576
|
+
const tag = crypto.randomUUID()
|
|
577
|
+
pending.set(tag, values)
|
|
578
|
+
return tag
|
|
579
|
+
}
|
|
580
|
+
const required = (operation: 'insert' | 'update' | 'delete'): void => {
|
|
581
|
+
if (actions[operation] === null) {
|
|
582
|
+
throw new Error(`the policy of ${table} declares no ${operation} action`)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const misuse = async (run: () => Promise<unknown>): Promise<{ readonly refused: boolean }> => {
|
|
586
|
+
try {
|
|
587
|
+
await run()
|
|
588
|
+
return { refused: false }
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (isCapabilityError(error)) {
|
|
591
|
+
return { refused: true }
|
|
592
|
+
}
|
|
593
|
+
throw error
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const seed = (): Promise<IsolationWorlds> => postgres.seedWorlds(tables, table)
|
|
597
|
+
const appReach = appReachKit(contract, tables, table, scope.key, postgres, seed)
|
|
598
|
+
const grantsIn = (worlds: IsolationWorlds): TestGrant<AnyContract>[] => {
|
|
599
|
+
const held = new Set([actions.read, actions.insert, actions.update, actions.delete].filter((action) => action !== null))
|
|
600
|
+
return [...held].map((action) => ({ action, scope: { type: worlds.dimension, value: worlds.own.scope } }))
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (scope.mode === 'dimension') {
|
|
604
|
+
const dimension = scope.dimension
|
|
605
|
+
const list = scope.container?.dimension ?? dimension
|
|
606
|
+
const probeContract = dimensionProbe(contract.vocabulary, dimension, list, actions)
|
|
607
|
+
const kit = testKit(
|
|
608
|
+
implement(probeContract, {
|
|
609
|
+
database: layer,
|
|
610
|
+
deps: () => ({}),
|
|
611
|
+
handlers: () => ({
|
|
612
|
+
probe: {
|
|
613
|
+
get: async ({ ctx }) => ({ columns: Object.keys(await probe.get(ctx.target)) }),
|
|
614
|
+
list: async ({ ctx }) => ({ keys: keysOf(await probe.select(ctx.within)) }),
|
|
615
|
+
count: async ({ ctx }) => ({ count: await probe.count(ctx.within) }),
|
|
616
|
+
insert: async ({ input, ctx }) => {
|
|
617
|
+
await probe.insert(ctx.target, valuesOf(input.tag))
|
|
618
|
+
return { done: true }
|
|
619
|
+
},
|
|
620
|
+
update: async ({ input, ctx }) => {
|
|
621
|
+
await probe.update(ctx.target, undefined, valuesOf(input.tag), expectVersion)
|
|
622
|
+
return { done: true }
|
|
623
|
+
},
|
|
624
|
+
remove: async ({ ctx }) => {
|
|
625
|
+
await probe.remove(ctx.target)
|
|
626
|
+
return { done: true }
|
|
627
|
+
},
|
|
628
|
+
updateWithRead: ({ input, ctx }) => misuse(() => probe.update(ctx.target, undefined, valuesOf(input.tag), expectVersion)),
|
|
629
|
+
removeWithRead: ({ ctx }) => misuse(() => probe.remove(ctx.target)),
|
|
630
|
+
},
|
|
631
|
+
}),
|
|
632
|
+
}),
|
|
633
|
+
{ database: postgres.database },
|
|
634
|
+
)
|
|
635
|
+
const session = (worlds: IsolationWorlds) => createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller(grantsIn(worlds))) })
|
|
636
|
+
const keyOf = (worlds: IsolationWorlds, row: ChildIsolationRow): string => {
|
|
637
|
+
if (row === 'crossed') {
|
|
638
|
+
throw new Error(`${table} is a dimension table; a crossed key addresses a child row`)
|
|
639
|
+
}
|
|
640
|
+
return worlds[row].key
|
|
641
|
+
}
|
|
642
|
+
const dimensionKit: PolicyIsolationKit = {
|
|
643
|
+
...appReach,
|
|
644
|
+
table,
|
|
645
|
+
dimension: list,
|
|
646
|
+
async get(row) {
|
|
647
|
+
const worlds = await seed()
|
|
648
|
+
const api = session(worlds)
|
|
649
|
+
return attempt(async () => ({ status: 200, ...(await api.get({ id: keyOf(worlds, row) })) }), (status) => ({ status, columns: [] }))
|
|
650
|
+
},
|
|
651
|
+
async list() {
|
|
652
|
+
const worlds = await seed()
|
|
653
|
+
const api = session(worlds)
|
|
654
|
+
return attempt(async () => ({ status: 200, rows: reached(worlds, (await api.list()).keys) }), (status) => ({ status, rows: [] }))
|
|
655
|
+
},
|
|
656
|
+
listByParent() {
|
|
657
|
+
return Promise.reject(new Error(`${table} is a dimension table; only a child table is listed by its parent`))
|
|
658
|
+
},
|
|
659
|
+
async count() {
|
|
660
|
+
const api = session(await seed())
|
|
661
|
+
return attempt(async () => ({ status: 200, ...(await api.count()) }), (status) => ({ status, count: '' }))
|
|
662
|
+
},
|
|
663
|
+
async insert(into) {
|
|
664
|
+
required('insert')
|
|
665
|
+
const worlds = await seed()
|
|
666
|
+
const api = session(worlds)
|
|
667
|
+
const tag = tagged(await postgres.sampleInsert(tables, table))
|
|
668
|
+
return wrote(() => api.insert({ scope: worlds[into].scope, tag }))
|
|
669
|
+
},
|
|
670
|
+
async update(row) {
|
|
671
|
+
required('update')
|
|
672
|
+
const worlds = await seed()
|
|
673
|
+
const api = session(worlds)
|
|
674
|
+
const id = keyOf(worlds, row)
|
|
675
|
+
const tag = tagged(worlds[row === 'other' ? 'other' : 'own'].updates)
|
|
676
|
+
return wrote(() => api.update({ id, tag }))
|
|
677
|
+
},
|
|
678
|
+
async remove(row) {
|
|
679
|
+
required('delete')
|
|
680
|
+
const worlds = await seed()
|
|
681
|
+
const api = session(worlds)
|
|
682
|
+
return wrote(() => api.remove({ id: keyOf(worlds, row) }))
|
|
683
|
+
},
|
|
684
|
+
async updateWithRead() {
|
|
685
|
+
const worlds = await seed()
|
|
686
|
+
const api = session(worlds)
|
|
687
|
+
const tag = tagged(worlds.own.updates)
|
|
688
|
+
return attempt(
|
|
689
|
+
async () => ({ status: 200, ...(await api.updateWithRead({ id: worlds.own.key, tag })) }),
|
|
690
|
+
(status) => ({ status, refused: false }),
|
|
691
|
+
)
|
|
692
|
+
},
|
|
693
|
+
async removeWithRead() {
|
|
694
|
+
const worlds = await seed()
|
|
695
|
+
const api = session(worlds)
|
|
696
|
+
return attempt(async () => ({ status: 200, ...(await api.removeWithRead({ id: worlds.own.key })) }), (status) => ({ status, refused: false }))
|
|
697
|
+
},
|
|
698
|
+
}
|
|
699
|
+
return Object.freeze(dimensionKit)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const parentPolicy = tablePolicies(tables)[scope.parent]
|
|
703
|
+
if (parentPolicy?.scope.mode !== 'dimension') {
|
|
704
|
+
throw new Error(`${table}: its parent ${scope.parent} is not a dimension table`)
|
|
705
|
+
}
|
|
706
|
+
const parent = parentPolicy.scope.dimension
|
|
707
|
+
const list = parentPolicy.scope.container?.dimension ?? parent
|
|
708
|
+
const probeContract = childProbe(contract.vocabulary, parent, list, actions)
|
|
709
|
+
const kit = testKit(
|
|
710
|
+
implement(probeContract, {
|
|
711
|
+
database: layer,
|
|
712
|
+
deps: () => ({}),
|
|
713
|
+
handlers: () => ({
|
|
714
|
+
probe: {
|
|
715
|
+
get: async ({ input, ctx }) => ({ columns: Object.keys(await probe.get(ctx.target, input.key)) }),
|
|
716
|
+
list: async ({ ctx }) => ({ keys: keysOf(await probe.select(ctx.within)) }),
|
|
717
|
+
listByParent: async ({ ctx }) => ({ keys: keysOf(await probe.select(ctx.target)) }),
|
|
718
|
+
count: async ({ ctx }) => ({ count: await probe.count(ctx.within) }),
|
|
719
|
+
insert: async ({ input, ctx }) => {
|
|
720
|
+
await probe.insert(ctx.target, valuesOf(input.tag))
|
|
721
|
+
return { done: true }
|
|
722
|
+
},
|
|
723
|
+
update: async ({ input, ctx }) => {
|
|
724
|
+
await probe.update(ctx.target, input.key, valuesOf(input.tag), expectVersion)
|
|
725
|
+
return { done: true }
|
|
726
|
+
},
|
|
727
|
+
remove: async ({ input, ctx }) => {
|
|
728
|
+
await probe.remove(ctx.target, input.key)
|
|
729
|
+
return { done: true }
|
|
730
|
+
},
|
|
731
|
+
updateWithRead: ({ input, ctx }) => misuse(() => probe.update(ctx.target, input.key, valuesOf(input.tag), expectVersion)),
|
|
732
|
+
removeWithRead: ({ input, ctx }) => misuse(() => probe.remove(ctx.target, input.key)),
|
|
733
|
+
},
|
|
734
|
+
}),
|
|
735
|
+
}),
|
|
736
|
+
{ database: postgres.database },
|
|
737
|
+
)
|
|
738
|
+
const session = (worlds: IsolationWorlds) => createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller(grantsIn(worlds))) })
|
|
739
|
+
/** The parent and key a request names: a crossed row is the other key under the caller's own parent. */
|
|
740
|
+
const address = (worlds: IsolationWorlds, row: ChildIsolationRow): { readonly parent: string; readonly key: string } => {
|
|
741
|
+
const parentOf = worlds[row === 'other' ? 'other' : 'own'].parent
|
|
742
|
+
if (parentOf === null) {
|
|
743
|
+
throw new Error(`${table} is a child table, yet its seeded row has no parent`)
|
|
744
|
+
}
|
|
745
|
+
return { parent: parentOf, key: worlds[row === 'own' ? 'own' : 'other'].key }
|
|
746
|
+
}
|
|
747
|
+
const childKit: PolicyIsolationKit = {
|
|
748
|
+
...appReach,
|
|
749
|
+
table,
|
|
750
|
+
dimension: list,
|
|
751
|
+
async get(row) {
|
|
752
|
+
const worlds = await seed()
|
|
753
|
+
const api = session(worlds)
|
|
754
|
+
return attempt(async () => ({ status: 200, ...(await api.get(address(worlds, row))) }), (status) => ({ status, columns: [] }))
|
|
755
|
+
},
|
|
756
|
+
async list() {
|
|
757
|
+
const worlds = await seed()
|
|
758
|
+
const api = session(worlds)
|
|
759
|
+
return attempt(async () => ({ status: 200, rows: reached(worlds, (await api.list()).keys) }), (status) => ({ status, rows: [] }))
|
|
760
|
+
},
|
|
761
|
+
async listByParent(row) {
|
|
762
|
+
const worlds = await seed()
|
|
763
|
+
const api = session(worlds)
|
|
764
|
+
const { parent: parentKey } = address(worlds, row)
|
|
765
|
+
return attempt(async () => ({ status: 200, rows: reached(worlds, (await api.listByParent({ parent: parentKey })).keys) }), (status) => ({
|
|
766
|
+
status,
|
|
767
|
+
rows: [],
|
|
768
|
+
}))
|
|
769
|
+
},
|
|
770
|
+
async count() {
|
|
771
|
+
const api = session(await seed())
|
|
772
|
+
return attempt(async () => ({ status: 200, ...(await api.count()) }), (status) => ({ status, count: '' }))
|
|
773
|
+
},
|
|
774
|
+
async insert(into) {
|
|
775
|
+
required('insert')
|
|
776
|
+
const worlds = await seed()
|
|
777
|
+
const api = session(worlds)
|
|
778
|
+
const tag = tagged(await postgres.sampleInsert(tables, table))
|
|
779
|
+
const { parent: parentKey } = address(worlds, into)
|
|
780
|
+
return wrote(() => api.insert({ parent: parentKey, tag }))
|
|
781
|
+
},
|
|
782
|
+
async update(row) {
|
|
783
|
+
required('update')
|
|
784
|
+
const worlds = await seed()
|
|
785
|
+
const api = session(worlds)
|
|
786
|
+
const tag = tagged(worlds[row === 'own' ? 'own' : 'other'].updates)
|
|
787
|
+
return wrote(() => api.update({ ...address(worlds, row), tag }))
|
|
788
|
+
},
|
|
789
|
+
async remove(row) {
|
|
790
|
+
required('delete')
|
|
791
|
+
const worlds = await seed()
|
|
792
|
+
const api = session(worlds)
|
|
793
|
+
return wrote(() => api.remove(address(worlds, row)))
|
|
794
|
+
},
|
|
795
|
+
async updateWithRead() {
|
|
796
|
+
const worlds = await seed()
|
|
797
|
+
const api = session(worlds)
|
|
798
|
+
const tag = tagged(worlds.own.updates)
|
|
799
|
+
return attempt(async () => ({ status: 200, ...(await api.updateWithRead({ ...address(worlds, 'own'), tag })) }), (status) => ({
|
|
800
|
+
status,
|
|
801
|
+
refused: false,
|
|
802
|
+
}))
|
|
803
|
+
},
|
|
804
|
+
async removeWithRead() {
|
|
805
|
+
const worlds = await seed()
|
|
806
|
+
const api = session(worlds)
|
|
807
|
+
return attempt(async () => ({ status: 200, ...(await api.removeWithRead(address(worlds, 'own'))) }), (status) => ({ status, refused: false }))
|
|
808
|
+
},
|
|
809
|
+
}
|
|
810
|
+
return Object.freeze(childKit)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* The scope type a `scoped` caller's grants name: the vocabulary's first dimension, or, in a vocabulary without one, a
|
|
815
|
+
* name IAM never grants in. Either way the grant is not global, which is all the app form looks at.
|
|
816
|
+
*/
|
|
817
|
+
export function scopeTypeOf(dimensions: object): string {
|
|
818
|
+
return Object.keys(dimensions).sort()[0] ?? 'scope'
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Which of the two seeded rows `keys` holds; a key of neither (a row another test seeded) is not one of them. */
|
|
822
|
+
function reached(worlds: IsolationWorlds, keys: readonly string[]): IsolationRow[] {
|
|
823
|
+
const rows: IsolationRow[] = []
|
|
824
|
+
if (keys.includes(worlds.own.key)) rows.push('own')
|
|
825
|
+
if (keys.includes(worlds.other.key)) rows.push('other')
|
|
826
|
+
return rows
|
|
827
|
+
}
|