@fabrika/app 0.3.3 → 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.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. 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
+ }