@aventara/core 1.0.0-rc.2 → 1.0.0-rc.4

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 (78) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/ERRORS.md +9 -9
  3. package/README.md +72 -12
  4. package/dist/adapters/adapter-operation.d.ts +7 -0
  5. package/dist/compiler/contract.compiler.d.ts +6 -0
  6. package/dist/compiler/contract.compiler.js +73 -12
  7. package/dist/config/computed-fields.js +2 -9
  8. package/dist/config/config-diagnostic.guidance.js +3 -3
  9. package/dist/config/config.resolver.d.ts +14 -0
  10. package/dist/config/config.resolver.js +31 -0
  11. package/dist/config/config.validator.js +82 -0
  12. package/dist/config/exact-framework-config.d.ts +8 -1
  13. package/dist/config/framework-config.d.ts +26 -1
  14. package/dist/contracts/client-contract-structure.validator.d.ts +8 -0
  15. package/dist/contracts/client-contract-structure.validator.js +6 -1
  16. package/dist/contracts/field.d.ts +11 -0
  17. package/dist/framework/contract-view.d.ts +41 -10
  18. package/dist/framework/framework.factory.js +39 -11
  19. package/dist/framework/operation-facade.d.ts +24 -5
  20. package/dist/framework/operation-facade.js +2 -1
  21. package/dist/index.d.ts +4 -1
  22. package/dist/internal.d.ts +1 -0
  23. package/dist/internal.js +1 -0
  24. package/dist/operations/database-refusal.d.ts +1 -1
  25. package/dist/operations/database-refusal.js +1 -1
  26. package/dist/operations/operation-arguments.d.ts +1 -1
  27. package/dist/operations/operation-call.d.ts +9 -5
  28. package/dist/operations/operation-result.d.ts +5 -3
  29. package/dist/operations/operation-validator-core.js +66 -33
  30. package/dist/operations/validation-code.guidance.js +2 -2
  31. package/dist/policy/policy-admission.d.ts +22 -0
  32. package/dist/policy/policy-admission.js +400 -0
  33. package/dist/policy/policy-after-update.runtime.d.ts +29 -0
  34. package/dist/policy/policy-after-update.runtime.js +131 -0
  35. package/dist/policy/policy-condition.d.ts +15 -0
  36. package/dist/policy/policy-condition.js +36 -0
  37. package/dist/policy/policy-engine.d.ts +111 -0
  38. package/dist/policy/policy-engine.js +1 -0
  39. package/dist/policy/policy-nested-write.runtime.d.ts +38 -0
  40. package/dist/policy/policy-nested-write.runtime.js +449 -0
  41. package/dist/policy/policy-read.runtime.d.ts +83 -0
  42. package/dist/policy/policy-read.runtime.js +494 -0
  43. package/dist/policy/policy-request.d.ts +37 -0
  44. package/dist/policy/policy-request.js +52 -0
  45. package/dist/policy/policy-session.d.ts +83 -0
  46. package/dist/policy/policy-session.js +234 -0
  47. package/dist/policy/policy-startup.validator.d.ts +8 -0
  48. package/dist/policy/policy-startup.validator.js +106 -0
  49. package/dist/policy/policy-write.runtime.d.ts +30 -0
  50. package/dist/policy/policy-write.runtime.js +394 -0
  51. package/dist/prebuilt/core-version.generated.d.ts +2 -0
  52. package/dist/prebuilt/core-version.generated.js +1 -0
  53. package/dist/prebuilt/prebuilt-contracts.d.ts +40 -0
  54. package/dist/prebuilt/prebuilt-contracts.emitter.d.ts +16 -0
  55. package/dist/prebuilt/prebuilt-contracts.emitter.js +50 -0
  56. package/dist/prebuilt/prebuilt-contracts.js +1 -0
  57. package/dist/prebuilt/prebuilt-contracts.loader.d.ts +21 -0
  58. package/dist/prebuilt/prebuilt-contracts.loader.js +122 -0
  59. package/dist/prebuilt/prebuilt-inputs.fingerprint.d.ts +16 -0
  60. package/dist/prebuilt/prebuilt-inputs.fingerprint.js +192 -0
  61. package/dist/runtime/computed-read-runtime.d.ts +6 -1
  62. package/dist/runtime/computed-read-runtime.js +224 -45
  63. package/dist/runtime/context-executor.runtime.js +4 -2
  64. package/dist/runtime/execution-options.d.ts +8 -0
  65. package/dist/runtime/field-validator.runtime.d.ts +9 -0
  66. package/dist/runtime/field-validator.runtime.js +36 -2
  67. package/dist/runtime/framework-warning.emitter.d.ts +6 -0
  68. package/dist/runtime/framework-warning.emitter.js +8 -0
  69. package/dist/runtime/level-lifecycle.runtime.js +3 -2
  70. package/dist/runtime/operation-code.guidance.js +4 -4
  71. package/dist/runtime/operation-request.d.ts +7 -0
  72. package/dist/runtime/operation-runtime.d.ts +9 -1
  73. package/dist/runtime/operation-runtime.js +119 -11
  74. package/dist/runtime/readonly-value.d.ts +5 -1
  75. package/dist/runtime/readonly-value.js +67 -28
  76. package/dist/transactions/transaction-runtime.d.ts +4 -1
  77. package/dist/transactions/transaction-runtime.js +27 -5
  78. package/package.json +2 -2
@@ -0,0 +1,111 @@
1
+ import type { AvRequestOrigin } from "../config/config-layer.js";
2
+ /** An answer now or later: every question may be answered asynchronously. */
3
+ type PolicyAnswer<T> = T | Promise<T>;
4
+ /**
5
+ * A fragment of Aventara's own `where` grammar, as a policy states a row
6
+ * condition: a scope, a field's per-row grant, or the question an engine's rule
7
+ * asks through `exists`. It is validated against the Core Contract when it is
8
+ * used, never the caller's, so it may name a field the caller cannot see
9
+ * (`ownerId`). One that does not validate is the server's defect: the request
10
+ * answers `A3000`.
11
+ */
12
+ export type AvPolicyWhere = {
13
+ readonly [key: string]: unknown;
14
+ };
15
+ /** What every question to the engine states. */
16
+ type PolicyQuestion = {
17
+ /** What the configuration's `policy.principal` derived for the request, or the `as` of an application call. */
18
+ readonly principal: unknown;
19
+ /** Which layer the request runs under. */
20
+ readonly origin: AvRequestOrigin;
21
+ /** The Resource the question is about. */
22
+ readonly resource: string;
23
+ };
24
+ /**
25
+ * The policy engine a Framework consults (root configuration `policy.engine`).
26
+ * Core asks it three questions per request and Resource, and enforces the
27
+ * answers: the operation (`can`), the rows (`scopeOf`) and the fields
28
+ * (`fieldsOf`). Every method may answer with a promise.
29
+ *
30
+ * `Governed` is the engine's `governed` set, stated by its type so the
31
+ * application's types leave those fields out of every default projection, as
32
+ * the Contracts do.
33
+ */
34
+ export interface AvPolicyEngine<Governed extends {
35
+ readonly [resource: string]: readonly string[];
36
+ } = {
37
+ readonly [resource: string]: readonly string[];
38
+ }> {
39
+ /**
40
+ * The fields and relations the policy governs, by Resource. Each is left out
41
+ * of every default projection, for every caller, and is the only trace the
42
+ * policy leaves in a Contract (a `governed: true` mark). A caller that wants
43
+ * one names it, and is refused (`A4002`) unless it may read it on every row
44
+ * it uses it on. Checked against the Core Contract at startup.
45
+ */
46
+ readonly governed?: Governed;
47
+ /**
48
+ * The relation paths and filter operators the engine's rules filter on, by
49
+ * the rule's name, each path a list of segments:
50
+ * `["Note", "campaign", "memberships", "some", "userId", "equals"]`, a
51
+ * Resource, then its fields and relations (a to-many relation followed by
52
+ * `some`, `every` or `none`), then the scalar field, then optionally the
53
+ * filter operator. Checked against the Core Contract at startup: a path it
54
+ * cannot filter fails construction, naming the rule.
55
+ */
56
+ readonly paths?: {
57
+ readonly [rule: string]: readonly (readonly string[])[];
58
+ };
59
+ /**
60
+ * The writes whose grants carry an `after` condition, by Resource: a row
61
+ * they change must still meet it once changed (`{ Note: ["update"] }`).
62
+ * Inside a transaction core asks `can` about each row every checked update
63
+ * changed, listed or not, and rolls the write back (`A4002`) when one fails.
64
+ * This list decides where the operation has no interactive transaction:
65
+ * these updates are refused there (`A4002`), and every other one runs
66
+ * without the after check. Checked against the Core Contract at startup.
67
+ */
68
+ readonly afterChecks?: {
69
+ readonly [resource: string]: readonly "update"[];
70
+ };
71
+ /**
72
+ * Whether the principal may run `action` on `resource`. Asked once per
73
+ * request before any developer stage, without a row: `false` refuses the
74
+ * request (`A4002`). Asked again with the row a write leaves: each row a
75
+ * create inserts (before the insert), and each row an update changed (after
76
+ * the write, inside its transaction; see `afterChecks`); `false` refuses the
77
+ * write. Rules that need a fact the row does not carry ask it
78
+ * through `exists`, which core runs through the adapter, unscoped, inside
79
+ * the write's transaction when it has one, and answers with a boolean only.
80
+ */
81
+ can(question: PolicyQuestion & {
82
+ readonly action: "read" | "create" | "update" | "delete";
83
+ readonly row?: Readonly<Record<string, unknown>>;
84
+ readonly exists: (resource: string, where: AvPolicyWhere) => Promise<boolean>;
85
+ }): PolicyAnswer<boolean>;
86
+ /**
87
+ * The rows the principal may `action`: `true` every row, `false` none, or a
88
+ * condition core ANDs into the request at every level that reads the
89
+ * Resource (its `where`, an included relation's, a relation filter, a
90
+ * relation count).
91
+ */
92
+ scopeOf(question: PolicyQuestion & {
93
+ readonly action: "read" | "update" | "delete";
94
+ }): PolicyAnswer<boolean | AvPolicyWhere>;
95
+ /**
96
+ * The fields the principal may read or write. `always` lists the fields it
97
+ * may use on every row it can reach (`"all"`: every field that is not
98
+ * governed); `perRow` maps a field it may use only on some rows to the
99
+ * condition those rows meet. A field in neither is refused (`A4002`)
100
+ * wherever the request names it.
101
+ */
102
+ fieldsOf(question: PolicyQuestion & {
103
+ readonly access: "read" | "write";
104
+ }): PolicyAnswer<{
105
+ readonly always: "all" | readonly string[];
106
+ readonly perRow: {
107
+ readonly [field: string]: AvPolicyWhere | undefined;
108
+ };
109
+ }>;
110
+ }
111
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import type { AvAdapterModel } from "../adapters/adapter-model.js";
2
+ import type { AvContractCore } from "../contracts/contract.js";
3
+ import type { AvOperationFailureResponse } from "../runtime/operation-response.js";
4
+ import { type PolicyCondition } from "./policy-condition.js";
5
+ import { type PolicySession } from "./policy-session.js";
6
+ type Row = Readonly<Record<string, unknown>>;
7
+ /** The rows a level's records write: new rows, or rows a condition reaches before the write. */
8
+ export type NestedParent = {
9
+ readonly kind: "new";
10
+ } | {
11
+ readonly kind: "existing";
12
+ readonly reach: PolicyCondition;
13
+ };
14
+ /** The checks after the write: told the root rows it wrote, the refusal or `undefined`. */
15
+ export type NestedWriteCheck = (rows: readonly Row[]) => Promise<AvOperationFailureResponse | undefined>;
16
+ /**
17
+ * What a nested write needs: a refusal before it runs, or the root record the
18
+ * adapter runs (its to-many `$update` and `$delete` narrowed to the rows within
19
+ * their scopes) and the check after it.
20
+ */
21
+ export type NestedWritePlan = {
22
+ readonly kind: "refused";
23
+ readonly response: AvOperationFailureResponse;
24
+ } | {
25
+ readonly kind: "planned";
26
+ readonly record: Row;
27
+ readonly after: NestedWriteCheck;
28
+ };
29
+ /** Whether `record` of `resource` writes a relation through a directive. */
30
+ export declare function writesNested(core: AvContractCore, resource: string, record: unknown): boolean;
31
+ /**
32
+ * The policy of the nested writes in one root record (see the module comment):
33
+ * the refusal before the call, or the checks after it. `created` says the root
34
+ * rows are created by this write, so they too are read as stored and asked
35
+ * `can({ action: "create", row })` after the call.
36
+ */
37
+ export declare function planNestedWrites(session: PolicySession, core: AvContractCore, model: AvAdapterModel, resource: string, record: Row, parent: NestedParent): Promise<NestedWritePlan>;
38
+ export {};
@@ -0,0 +1,449 @@
1
+ import { lookupField, lookupResource } from "../contracts/contract-lookup.js";
2
+ import { isRelationMutationDirective } from "../runtime/mutation-planner.js";
3
+ import { frozenCopy } from "../runtime/readonly-value.js";
4
+ import { policyRefusal } from "./policy-admission.js";
5
+ import { afterUpdate, checkChanged, rowFieldsOf, } from "./policy-after-update.runtime.js";
6
+ import { allOf, noneOf, someOf, whereOf, } from "./policy-condition.js";
7
+ import { identifiedRows, identifierOf } from "./policy-read.runtime.js";
8
+ import { fieldAccess } from "./policy-session.js";
9
+ function isRecord(value) {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+ function isPlainObject(value) {
13
+ if (!isRecord(value)) {
14
+ return false;
15
+ }
16
+ const prototype = Object.getPrototypeOf(value);
17
+ return prototype === Object.prototype || prototype === null;
18
+ }
19
+ const DROPPED = Symbol("dropped");
20
+ function rewritten(value, replaced) {
21
+ if (value !== null && typeof value === "object" && replaced.has(value)) {
22
+ return replaced.get(value);
23
+ }
24
+ if (Array.isArray(value)) {
25
+ const entries = value.map((entry) => rewritten(entry, replaced));
26
+ return entries.every((entry, index) => entry === value[index])
27
+ ? value
28
+ : entries.filter((entry) => entry !== DROPPED);
29
+ }
30
+ if (!isPlainObject(value)) {
31
+ return value;
32
+ }
33
+ let changed = false;
34
+ const copy = {};
35
+ for (const [key, member] of Object.entries(value)) {
36
+ const next = rewritten(member, replaced);
37
+ changed ||= next !== member;
38
+ copy[key] = next === DROPPED ? [] : next;
39
+ }
40
+ return changed ? copy : value;
41
+ }
42
+ function conditionOf(where) {
43
+ return isRecord(where) && Object.keys(where).length > 0 ? where : true;
44
+ }
45
+ function relationOf(core, resource, name) {
46
+ const field = lookupField(lookupResource(core, resource), name);
47
+ return field?.kind === "relation"
48
+ ? field
49
+ : undefined;
50
+ }
51
+ export function writesNested(core, resource, record) {
52
+ return (isRecord(record) &&
53
+ Object.entries(record).some(([key, value]) => isRecord(value) &&
54
+ relationOf(core, resource, key) !== undefined &&
55
+ Object.keys(value).some(isRelationMutationDirective)));
56
+ }
57
+ class NestedRefusal extends Error {
58
+ name = "NestedRefusal";
59
+ }
60
+ const QUESTION_MESSAGES = {
61
+ link: (resource) => `The policy does not allow this nested write to link or unlink a row of Resource "${resource}" outside what the caller may change or read.`,
62
+ update: (resource) => `The policy does not allow this nested write to change a row of Resource "${resource}" outside what the caller may change.`,
63
+ delete: (resource) => `The policy does not allow this nested write to delete a row of Resource "${resource}" outside what the caller may delete.`,
64
+ fields: (resource) => `The policy does not allow this nested write to write a field of Resource "${resource}" on every row it reaches.`,
65
+ };
66
+ function linkedTo(inverse, parents) {
67
+ if (parents === false) {
68
+ return false;
69
+ }
70
+ const reach = whereOf(parents);
71
+ return inverse.cardinality === "one"
72
+ ? { [inverse.name]: reach }
73
+ : { [inverse.name]: { some: reach } };
74
+ }
75
+ class NestedWalk {
76
+ session;
77
+ core;
78
+ model;
79
+ questions = new Map();
80
+ replaced = new Map();
81
+ constructor(session, core, model) {
82
+ this.session = session;
83
+ this.core = core;
84
+ this.model = model;
85
+ }
86
+ ask(resource, kind, condition) {
87
+ if (condition === false) {
88
+ return;
89
+ }
90
+ const key = `${resource}\u0000${kind}`;
91
+ let question = this.questions.get(key);
92
+ if (question === undefined) {
93
+ question = { resource, kind, conditions: [] };
94
+ this.questions.set(key, question);
95
+ }
96
+ question.conditions.push(condition);
97
+ }
98
+ async answer() {
99
+ for (const { resource, kind, conditions } of this.questions.values()) {
100
+ const reach = someOf(conditions);
101
+ if (reach !== false &&
102
+ (await this.session.exists(resource, whereOf(reach)))) {
103
+ return policyRefusal(QUESTION_MESSAGES[kind](resource));
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ identifier(resource) {
109
+ const identifier = identifierOf(this.model, resource);
110
+ if (identifier.length === 0) {
111
+ throw new NestedRefusal(`The policy checks the rows a nested write reaches in Resource "${resource}" by identifier, and the Resource has none.`);
112
+ }
113
+ return identifier;
114
+ }
115
+ async grantsOf(resource, records) {
116
+ const fields = await this.session.fields(resource, "write");
117
+ const grants = new Map();
118
+ for (const record of records) {
119
+ for (const [name, value] of Object.entries(record)) {
120
+ if (value === undefined) {
121
+ continue;
122
+ }
123
+ const relation = relationOf(this.core, resource, name);
124
+ for (const member of [name, ...(relation?.referenceFields ?? [])]) {
125
+ const access = fieldAccess(this.core, resource, fields, member);
126
+ if (access === "never") {
127
+ throw new NestedRefusal(`The policy does not allow writing field "${member}" of Resource "${resource}".`);
128
+ }
129
+ if (access !== "always") {
130
+ grants.set(member, access);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ return [...grants.values()];
136
+ }
137
+ async level(resource, parent, records) {
138
+ const relations = new Map();
139
+ for (const record of records) {
140
+ for (const [key, value] of Object.entries(record)) {
141
+ if (!isRecord(value) ||
142
+ relationOf(this.core, resource, key) === undefined) {
143
+ continue;
144
+ }
145
+ for (const [directive, operand] of Object.entries(value)) {
146
+ if (isRelationMutationDirective(directive) && operand !== undefined) {
147
+ let operands = relations.get(key);
148
+ if (operands === undefined) {
149
+ operands = [];
150
+ relations.set(key, operands);
151
+ }
152
+ operands.push([directive, operand]);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ const checks = [];
158
+ for (const [name, operands] of relations) {
159
+ checks.push(...(await this.relation(resource, parent, name, operands)));
160
+ }
161
+ return checks;
162
+ }
163
+ async relation(resource, parent, name, operands) {
164
+ const core = this.core;
165
+ const relation = relationOf(core, resource, name);
166
+ const target = relation.target;
167
+ const back = relation.inverse === undefined
168
+ ? undefined
169
+ : relationOf(core, target, relation.inverse);
170
+ const inverse = back === undefined || relation.inverse === undefined
171
+ ? undefined
172
+ : { name: relation.inverse, cardinality: back.cardinality };
173
+ const throughInverse = () => {
174
+ if (inverse === undefined) {
175
+ throw new NestedRefusal(`The policy checks the rows relation "${name}" of Resource "${resource}" reaches through its other end, and the relation states none.`);
176
+ }
177
+ return inverse;
178
+ };
179
+ let reached;
180
+ const linked = () => {
181
+ reached ??=
182
+ parent.kind === "new"
183
+ ? false
184
+ : linkedTo(throughInverse(), parent.reach);
185
+ return reached;
186
+ };
187
+ const read = await this.session.scope(target, "read");
188
+ const change = allOf(await this.session.scope(target, "update"), read);
189
+ const remove = allOf(await this.session.scope(target, "delete"), read);
190
+ const targetHolds = relation.referenceFields === undefined;
191
+ const linkScope = targetHolds ? change : read;
192
+ const many = relation.cardinality === "many";
193
+ const selectors = [];
194
+ const created = [];
195
+ const checks = [];
196
+ const changing = [];
197
+ const link = (selector) => {
198
+ if (isRecord(selector)) {
199
+ selectors.push(selector);
200
+ this.ask(target, "link", allOf(selector, noneOf(linkScope)));
201
+ if (targetHolds) {
202
+ changing.push(selector);
203
+ }
204
+ }
205
+ };
206
+ const unlinkLinked = (except) => {
207
+ const unlinked = allOf(linked(), noneOf(except));
208
+ this.ask(target, "link", allOf(unlinked, noneOf(linkScope)));
209
+ if (targetHolds) {
210
+ changing.push(unlinked);
211
+ }
212
+ };
213
+ const entriesOf = (operand) => many && Array.isArray(operand) ? operand : [operand];
214
+ for (const [directive, operand] of operands) {
215
+ if (!many &&
216
+ targetHolds &&
217
+ parent.kind === "existing" &&
218
+ (directive === "$create" ||
219
+ directive === "$connect" ||
220
+ directive === "$connectOrCreate" ||
221
+ directive === "$set")) {
222
+ unlinkLinked(false);
223
+ }
224
+ switch (directive) {
225
+ case "$create":
226
+ created.push(...entriesOf(operand).filter(isRecord));
227
+ break;
228
+ case "$connect":
229
+ entriesOf(operand).forEach(link);
230
+ break;
231
+ case "$connectOrCreate":
232
+ for (const entry of entriesOf(operand)) {
233
+ if (isRecord(entry)) {
234
+ link(entry.where);
235
+ if (isRecord(entry.create)) {
236
+ created.push(entry.create);
237
+ }
238
+ }
239
+ }
240
+ break;
241
+ case "$disconnect":
242
+ if (many) {
243
+ entriesOf(operand).forEach(link);
244
+ }
245
+ else {
246
+ unlinkLinked(false);
247
+ }
248
+ break;
249
+ case "$set":
250
+ if (!many) {
251
+ if (operand === null) {
252
+ unlinkLinked(false);
253
+ }
254
+ else {
255
+ link(operand);
256
+ }
257
+ break;
258
+ }
259
+ {
260
+ const kept = (Array.isArray(operand) ? operand : []).filter(isRecord);
261
+ kept.forEach(link);
262
+ unlinkLinked(someOf(kept));
263
+ }
264
+ break;
265
+ case "$update":
266
+ for (const entry of entriesOf(operand)) {
267
+ if (isRecord(entry) && isRecord(entry.data)) {
268
+ checks.push(await this.changed(target, allOf(linked(), conditionOf(entry.where)), change, entry.data, many ? entry : undefined));
269
+ }
270
+ }
271
+ break;
272
+ case "$upsert":
273
+ for (const entry of entriesOf(operand)) {
274
+ if (!isRecord(entry)) {
275
+ continue;
276
+ }
277
+ const reach = allOf(linked(), conditionOf(entry.where));
278
+ if (reach !== false &&
279
+ isRecord(entry.update) &&
280
+ (await this.session.exists(target, whereOf(reach)))) {
281
+ checks.push(await this.changed(target, reach, change, entry.update));
282
+ }
283
+ else if (isRecord(entry.create)) {
284
+ created.push(entry.create);
285
+ }
286
+ }
287
+ break;
288
+ case "$delete":
289
+ for (const entry of entriesOf(operand)) {
290
+ const reach = allOf(linked(), conditionOf(isRecord(entry) ? entry.where : undefined));
291
+ if (many && isRecord(entry)) {
292
+ await this.narrow(entry, target, reach, remove);
293
+ }
294
+ else {
295
+ this.ask(target, "delete", allOf(reach, noneOf(remove)));
296
+ }
297
+ }
298
+ break;
299
+ }
300
+ }
301
+ if (changing.length > 0) {
302
+ checks.push(await this.relinked(target, inverse, read, changing));
303
+ }
304
+ if (created.length > 0) {
305
+ checks.push(await this.creates(resource, target, throughInverse(), linked(), selectors, created));
306
+ }
307
+ return checks;
308
+ }
309
+ async narrow(entry, resource, reach, scope) {
310
+ const identifier = this.identifier(resource);
311
+ const within = allOf(reach, scope);
312
+ const rows = within === false
313
+ ? []
314
+ : await this.session.rows(resource, whereOf(within), identifier);
315
+ const where = identifiedRows(this.core, identifier, resource, rows);
316
+ this.replaced.set(entry, where === false ? DROPPED : { ...entry, where: whereOf(where) });
317
+ return rows;
318
+ }
319
+ async changed(resource, reach, scope, data, entry) {
320
+ const identifier = this.identifier(resource);
321
+ let rows;
322
+ if (entry === undefined) {
323
+ this.ask(resource, "update", allOf(reach, noneOf(scope)));
324
+ rows =
325
+ reach === false
326
+ ? []
327
+ : await this.session.rows(resource, whereOf(reach), identifier);
328
+ }
329
+ else {
330
+ rows = await this.narrow(entry, resource, reach, scope);
331
+ reach =
332
+ rows.length === 0
333
+ ? false
334
+ : identifiedRows(this.core, identifier, resource, rows);
335
+ }
336
+ const grants = await this.grantsOf(resource, [data]);
337
+ if (grants.length > 0) {
338
+ this.ask(resource, "fields", allOf(reach, someOf(grants.map(noneOf))));
339
+ }
340
+ const after = await afterUpdate(this.session, this.core, this.model, resource, data, true);
341
+ const inner = await this.level(resource, { kind: "existing", reach }, [
342
+ data,
343
+ ]);
344
+ return async () => {
345
+ if (after !== undefined) {
346
+ const refused = await checkChanged(this.session, this.core, this.model, resource, after, rows);
347
+ if (refused !== undefined) {
348
+ return refused;
349
+ }
350
+ }
351
+ return runChecks(inner, rows);
352
+ };
353
+ }
354
+ async relinked(resource, inverse, read, changing) {
355
+ const identifier = this.identifier(resource);
356
+ const reach = someOf(changing);
357
+ const rows = reach === false
358
+ ? []
359
+ : await this.session.rows(resource, whereOf(reach), identifier);
360
+ const after = (inverse === undefined
361
+ ? undefined
362
+ : await afterUpdate(this.session, this.core, this.model, resource, { [inverse.name]: {} }, true)) ?? { moveOut: read === true ? undefined : read, after: true };
363
+ return () => checkChanged(this.session, this.core, this.model, resource, after, rows);
364
+ }
365
+ async creates(parentResource, resource, inverse, linked, selectors, records) {
366
+ const identifier = this.identifier(resource);
367
+ const parentIdentifier = this.identifier(parentResource);
368
+ const there = someOf([linked, ...selectors]);
369
+ const before = there === false
370
+ ? []
371
+ : await this.session.rows(resource, whereOf(there), identifier);
372
+ const grants = await this.grantsOf(resource, records);
373
+ const inner = await this.level(resource, { kind: "new" }, records);
374
+ return async (parents) => {
375
+ if (parents.length === 0) {
376
+ return undefined;
377
+ }
378
+ const fresh = this.session.renewed();
379
+ const reach = allOf(linkedTo(inverse, identifiedRows(this.core, parentIdentifier, parentResource, parents)), before.length === 0
380
+ ? true
381
+ : noneOf(identifiedRows(this.core, identifier, resource, before)));
382
+ const rows = reach === false
383
+ ? []
384
+ : await fresh.rows(resource, whereOf(reach), [
385
+ ...new Set([...identifier, ...rowFieldsOf(this.core, resource)]),
386
+ ]);
387
+ const refused = await checkCreatedRows(fresh, this.core, identifier, resource, grants, rows);
388
+ return refused ?? runChecks(inner, rows);
389
+ };
390
+ }
391
+ }
392
+ async function runChecks(checks, rows) {
393
+ for (const check of checks) {
394
+ const refused = await check(rows);
395
+ if (refused !== undefined) {
396
+ return refused;
397
+ }
398
+ }
399
+ return undefined;
400
+ }
401
+ async function checkCreatedRows(session, core, identifier, resource, grants, rows) {
402
+ if (rows.length === 0) {
403
+ return undefined;
404
+ }
405
+ const answers = await Promise.all(rows.map((row) => session.can(resource, "create", frozenCopy(row))));
406
+ if (answers.includes(false)) {
407
+ return policyRefusal(`The policy does not allow creating this row of Resource "${resource}".`);
408
+ }
409
+ const missed = allOf(identifiedRows(core, identifier, resource, rows), someOf(grants.map(noneOf)));
410
+ if (missed !== false && (await session.exists(resource, whereOf(missed)))) {
411
+ return policyRefusal(`The policy does not allow writing a field of Resource "${resource}" on the row this request creates.`);
412
+ }
413
+ return undefined;
414
+ }
415
+ export async function planNestedWrites(session, core, model, resource, record, parent) {
416
+ const walk = new NestedWalk(session, core, model);
417
+ try {
418
+ const identifier = identifierOf(model, resource);
419
+ if (identifier.length === 0) {
420
+ throw new NestedRefusal(`The policy checks the rows a nested write reaches by identifier, and Resource "${resource}" has none.`);
421
+ }
422
+ const checks = await walk.level(resource, parent, [record]);
423
+ const refused = await walk.answer();
424
+ if (refused !== undefined) {
425
+ return { kind: "refused", response: refused };
426
+ }
427
+ return {
428
+ kind: "planned",
429
+ record: rewritten(record, walk.replaced),
430
+ after: async (rows) => {
431
+ if (parent.kind === "new" && rows.length > 0) {
432
+ const fresh = session.renewed();
433
+ const stored = await fresh.rows(resource, whereOf(identifiedRows(core, identifier, resource, rows)), [...new Set([...identifier, ...rowFieldsOf(core, resource)])]);
434
+ const refusal = await checkCreatedRows(fresh, core, identifier, resource, [], stored);
435
+ if (refusal !== undefined) {
436
+ return refusal;
437
+ }
438
+ }
439
+ return runChecks(checks, rows);
440
+ },
441
+ };
442
+ }
443
+ catch (error) {
444
+ if (error instanceof NestedRefusal) {
445
+ return { kind: "refused", response: policyRefusal(error.message) };
446
+ }
447
+ throw error;
448
+ }
449
+ }
@@ -0,0 +1,83 @@
1
+ import type { AvAdapterModel } from "../adapters/adapter-model.js";
2
+ import type { AvContractCore } from "../contracts/contract.js";
3
+ import type { AvOperationIdentity } from "../operations/operation-identity.js";
4
+ import type { PlannedReadOperation, ReadProjectionPlan } from "../runtime/computed-read-runtime.js";
5
+ import type { AvOperationFailureResponse } from "../runtime/operation-response.js";
6
+ import { type PolicyCondition } from "./policy-condition.js";
7
+ import { type PolicySession } from "./policy-session.js";
8
+ /** The answer once the policy checked it: its rows, or the refusal. */
9
+ export type PolicySettlement = {
10
+ readonly ok: true;
11
+ readonly data: unknown;
12
+ } | {
13
+ readonly ok: false;
14
+ readonly response: AvOperationFailureResponse;
15
+ };
16
+ /** What one read runs once its policy is applied. */
17
+ export type PolicyRead = {
18
+ /** The policy refuses the read before it runs. */
19
+ readonly kind: "refused";
20
+ readonly response: AvOperationFailureResponse;
21
+ } | {
22
+ /** The read reaches no row: its answer, without the adapter. */
23
+ readonly kind: "empty";
24
+ readonly data: unknown;
25
+ } | {
26
+ readonly kind: "run";
27
+ /** The operation the adapter runs: the request's own, or a unique read's first-style form. */
28
+ readonly identity: AvOperationIdentity;
29
+ readonly arguments: unknown;
30
+ readonly plan: PlannedReadOperation;
31
+ /**
32
+ * What the returned rows are checked for after the adapter answers (an
33
+ * `exists` per level): the root level's conditions, each a row it must
34
+ * not meet, and whether a level below the root is checked.
35
+ */
36
+ readonly checks: {
37
+ readonly root: readonly PolicyCondition[];
38
+ readonly nested: boolean;
39
+ };
40
+ /** The returned rows checked, and every emptied relation filled. */
41
+ readonly settle: (data: unknown) => Promise<PolicySettlement>;
42
+ };
43
+ /**
44
+ * How a write's root rows are reached (`policy-write.runtime.ts`): the
45
+ * condition ANDed into its `where`, or `undefined` for a write whose `where`
46
+ * stays as sent (a create has none, an upsert's is checked before it runs);
47
+ * and whether each returned row carries its identifier, for the checks that
48
+ * follow the write.
49
+ */
50
+ export type PolicyRootRows = {
51
+ readonly scope: PolicyCondition | undefined;
52
+ readonly identified: boolean;
53
+ /**
54
+ * The fields each returned root row carries besides the caller's projection (its
55
+ * identifier among them when `identified`): what the after check asks `can` about,
56
+ * read from the write's own answer rather than read again. Trimmed before the
57
+ * caller sees it.
58
+ */
59
+ readonly carried?: readonly string[];
60
+ };
61
+ type Row = Readonly<Record<string, unknown>>;
62
+ /** The Resource's identifier as the adapter states it first (its primary key). */
63
+ export declare function identifierOf(model: AvAdapterModel, resource: string): readonly string[];
64
+ /**
65
+ * One read's policy (see the module comment): what the adapter runs, and the
66
+ * checks its answer goes through; or the empty answer when the read reaches no
67
+ * row. A write's answer is read the same way, its root reached as `rows`
68
+ * says (`policy-write.runtime.ts`); a read's root is reached by its read scope.
69
+ */
70
+ export declare function enforceReadPolicy(session: PolicySession, core: AvContractCore, model: AvAdapterModel, request: AvOperationIdentity & {
71
+ readonly resource: string;
72
+ }, plan: PlannedReadOperation, adapterArguments: unknown, rows?: PolicyRootRows): Promise<PolicyRead>;
73
+ /**
74
+ * What the rows an answer returns are checked for after the adapter answers, by
75
+ * the projection level that returns them: each level's conditions, each a row
76
+ * it must not meet (a to-one level's rows outside their read scope, a level's
77
+ * rows outside the grant of a field it returns that is readable on some rows
78
+ * only). Empty when nothing is checked after the answer.
79
+ */
80
+ export declare function returnedRowChecks(session: PolicySession, core: AvContractCore, plan: PlannedReadOperation): Promise<Map<ReadProjectionPlan, readonly PolicyCondition[]>>;
81
+ /** A condition naming exactly `rows` of `resource` by their identifier. */
82
+ export declare function identifiedRows(core: AvContractCore, identifier: readonly string[], resource: string, rows: readonly Row[]): PolicyCondition;
83
+ export {};