@unson/brainbase-mcp 0.6.0 → 0.7.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/dist/canonical-task-contract.d.ts +9 -0
- package/dist/canonical-task-contract.js +39 -0
- package/dist/canonical-task-principal.d.ts +16 -0
- package/dist/canonical-task-principal.js +36 -0
- package/dist/canonical-task-service.d.ts +251 -0
- package/dist/canonical-task-service.js +809 -0
- package/dist/company-os-evaluation.d.ts +193 -0
- package/dist/company-os-evaluation.js +1150 -0
- package/dist/company-os-impact-review.d.ts +248 -0
- package/dist/company-os-impact-review.js +663 -0
- package/dist/company-os-learning-adoption.d.ts +288 -0
- package/dist/company-os-learning-adoption.js +944 -0
- package/dist/company-os-objectives.d.ts +52 -0
- package/dist/company-os-objectives.js +197 -0
- package/dist/company-os-receipt-adapter.d.ts +109 -0
- package/dist/company-os-receipt-adapter.js +636 -0
- package/dist/constraint-exception-store.d.ts +23 -0
- package/dist/constraint-exception-store.js +108 -0
- package/dist/constraint-resolution.d.ts +175 -0
- package/dist/constraint-resolution.js +922 -0
- package/dist/decision-adapter.d.ts +145 -0
- package/dist/decision-adapter.js +586 -0
- package/dist/durable-wait-http.d.ts +51 -0
- package/dist/durable-wait-http.js +497 -0
- package/dist/durable-waits.d.ts +234 -0
- package/dist/durable-waits.js +944 -0
- package/dist/execution-authority-http.d.ts +28 -0
- package/dist/execution-authority-http.js +359 -0
- package/dist/execution-authority.d.ts +201 -0
- package/dist/execution-authority.js +634 -0
- package/dist/foundation-catalog.d.ts +26 -0
- package/dist/foundation-catalog.js +206 -0
- package/dist/foundation-constraint-store.d.ts +50 -0
- package/dist/foundation-constraint-store.js +215 -0
- package/dist/foundation-http.d.ts +90 -0
- package/dist/foundation-http.js +439 -0
- package/dist/foundation-store.d.ts +132 -0
- package/dist/foundation-store.js +654 -0
- package/dist/judgment-dag-composition.d.ts +224 -0
- package/dist/judgment-dag-composition.js +731 -0
- package/dist/judgment-dag.d.ts +2 -0
- package/dist/judgment-dag.js +1 -0
- package/dist/judgment-problem-snapshot.d.ts +185 -0
- package/dist/judgment-problem-snapshot.js +777 -0
- package/dist/judgment-view-http.d.ts +22 -0
- package/dist/judgment-view-http.js +128 -0
- package/dist/judgment-view.d.ts +222 -0
- package/dist/judgment-view.js +627 -0
- package/dist/knowledge-adapter.d.ts +112 -0
- package/dist/knowledge-adapter.js +691 -0
- package/dist/ontology-foundation.d.ts +209 -0
- package/dist/ontology-foundation.js +1000 -0
- package/dist/ontology.d.ts +13 -0
- package/dist/ontology.js +14 -0
- package/dist/organization-connection.d.ts +164 -0
- package/dist/organization-connection.js +428 -0
- package/dist/personal-knowledge.d.ts +120 -0
- package/dist/personal-knowledge.js +750 -0
- package/dist/problem-candidates.d.ts +149 -0
- package/dist/problem-candidates.js +898 -0
- package/dist/problem-selection.d.ts +238 -0
- package/dist/problem-selection.js +995 -0
- package/dist/resource-reservation-http.d.ts +31 -0
- package/dist/resource-reservation-http.js +371 -0
- package/dist/resource-reservations.d.ts +221 -0
- package/dist/resource-reservations.js +577 -0
- package/dist/server.d.ts +97 -0
- package/dist/server.js +180 -1
- package/dist/ssot.d.ts +6 -1
- package/dist/ssot.js +63 -5
- package/dist/streamable-http.d.ts +42 -0
- package/dist/streamable-http.js +288 -0
- package/dist/types.d.ts +20 -0
- package/dist/world-model.d.ts +181 -0
- package/dist/world-model.js +873 -0
- package/package.json +139 -3
- package/ui/judgment-view.css +238 -0
- package/ui/judgment-view.js +508 -0
- package/ui/objective-editor.css +180 -0
- package/ui/objective-editor.js +1139 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constraint resolution boundary for the company judgment foundation.
|
|
3
|
+
*
|
|
4
|
+
* The ontology package is the only owner of the four foundation definitions.
|
|
5
|
+
* This module aliases its ConstraintDefinition and re-exports the shared
|
|
6
|
+
* value contracts for resolver-specific APIs without defining another Graph
|
|
7
|
+
* entity shape.
|
|
8
|
+
*/
|
|
9
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
10
|
+
export class ConstraintError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
status;
|
|
13
|
+
details;
|
|
14
|
+
constructor(code, message, status = 400, details = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ConstraintError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.details = details;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const constraintUses = new Set([
|
|
23
|
+
'draft',
|
|
24
|
+
'judgment',
|
|
25
|
+
'evaluation',
|
|
26
|
+
'execution',
|
|
27
|
+
]);
|
|
28
|
+
const adoptionStates = new Set([
|
|
29
|
+
'draft',
|
|
30
|
+
'proposed',
|
|
31
|
+
'approved',
|
|
32
|
+
'retired',
|
|
33
|
+
]);
|
|
34
|
+
const visibilityValues = new Set([
|
|
35
|
+
'private',
|
|
36
|
+
'project',
|
|
37
|
+
'organization',
|
|
38
|
+
'public',
|
|
39
|
+
]);
|
|
40
|
+
const storageValues = new Set([
|
|
41
|
+
'candidate',
|
|
42
|
+
'ontology',
|
|
43
|
+
'evidence',
|
|
44
|
+
'execution',
|
|
45
|
+
]);
|
|
46
|
+
const provenanceKinds = new Set([
|
|
47
|
+
'candidate',
|
|
48
|
+
'document',
|
|
49
|
+
'observation',
|
|
50
|
+
'decision',
|
|
51
|
+
'import',
|
|
52
|
+
]);
|
|
53
|
+
function fail(code, message, status = 400, details = {}) {
|
|
54
|
+
throw new ConstraintError(code, message, status, details);
|
|
55
|
+
}
|
|
56
|
+
function clone(value) {
|
|
57
|
+
return structuredClone(value);
|
|
58
|
+
}
|
|
59
|
+
function isRecord(value) {
|
|
60
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
function requiredText(value, field) {
|
|
63
|
+
if (typeof value !== 'string' || value.trim().length === 0 || /[\u0000-\u001F\u007F]/u.test(value)) {
|
|
64
|
+
fail('invalid_constraint', `${field} must be a non-empty text value`, 400, { field });
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function optionalText(value, field) {
|
|
69
|
+
if (value === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
return requiredText(value, field);
|
|
72
|
+
}
|
|
73
|
+
function parseTimestamp(value, field) {
|
|
74
|
+
const text = requiredText(value, field);
|
|
75
|
+
const parsed = parseStrictRfc3339(text);
|
|
76
|
+
if (parsed === undefined) {
|
|
77
|
+
fail('invalid_scope', `${field} must be an RFC3339 timestamp`, 400, { field, value });
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Date.parse accepts calendar values such as 2026-02-30 and silently moves
|
|
83
|
+
* them into March. Scope boundaries are part of the ontology contract, so a
|
|
84
|
+
* resolver must reject the value instead of changing its meaning.
|
|
85
|
+
*/
|
|
86
|
+
function parseStrictRfc3339(value) {
|
|
87
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/u.exec(value);
|
|
88
|
+
if (!match)
|
|
89
|
+
return undefined;
|
|
90
|
+
const year = Number(match[1]);
|
|
91
|
+
const month = Number(match[2]);
|
|
92
|
+
const day = Number(match[3]);
|
|
93
|
+
const hour = Number(match[4]);
|
|
94
|
+
const minute = Number(match[5]);
|
|
95
|
+
const second = Number(match[6]);
|
|
96
|
+
const fraction = match[7] ?? '';
|
|
97
|
+
const zone = match[8];
|
|
98
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month))
|
|
99
|
+
return undefined;
|
|
100
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
101
|
+
return undefined;
|
|
102
|
+
let offsetMinutes = 0;
|
|
103
|
+
if (zone !== 'Z') {
|
|
104
|
+
const sign = zone[0] === '-' ? -1 : 1;
|
|
105
|
+
const offsetHour = Number(zone.slice(1, 3));
|
|
106
|
+
const offsetMinute = Number(zone.slice(4, 6));
|
|
107
|
+
if (offsetHour > 23 || offsetMinute > 59)
|
|
108
|
+
return undefined;
|
|
109
|
+
offsetMinutes = sign * (offsetHour * 60 + offsetMinute);
|
|
110
|
+
}
|
|
111
|
+
// Date.UTC treats years 0..99 as 1900..1999. The contract accepts a
|
|
112
|
+
// four-digit year, so set those years back explicitly after conversion.
|
|
113
|
+
const milliseconds = Number((fraction + '000').slice(0, 3));
|
|
114
|
+
let timestamp = Date.UTC(year, month - 1, day, hour, minute, second, milliseconds);
|
|
115
|
+
if (year < 100) {
|
|
116
|
+
const normalized = new Date(timestamp);
|
|
117
|
+
normalized.setUTCFullYear(year);
|
|
118
|
+
timestamp = normalized.getTime();
|
|
119
|
+
}
|
|
120
|
+
timestamp -= offsetMinutes * 60_000;
|
|
121
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
122
|
+
}
|
|
123
|
+
function daysInMonth(year, month) {
|
|
124
|
+
if (month === 2) {
|
|
125
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
126
|
+
}
|
|
127
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
128
|
+
}
|
|
129
|
+
function validateRevision(value, field = 'revision') {
|
|
130
|
+
if (typeof value !== 'string' || value.trim().length === 0 || /[\u0000-\u001F\u007F]/u.test(value)) {
|
|
131
|
+
fail('invalid_revision', `${field} must be a non-empty revision`, 400, { field });
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
function validateUniqueIds(value, field, options = {}) {
|
|
136
|
+
if (!Array.isArray(value) || (!options.allowEmpty && value.length === 0)) {
|
|
137
|
+
fail('invalid_scope', `${field} must be a non-empty array`, 400, { field });
|
|
138
|
+
}
|
|
139
|
+
const ids = value.map((item, index) => requiredText(item, `${field}[${index}]`));
|
|
140
|
+
if (new Set(ids).size !== ids.length) {
|
|
141
|
+
fail('invalid_scope', `${field} must not contain duplicate ids`, 400, { field });
|
|
142
|
+
}
|
|
143
|
+
return ids;
|
|
144
|
+
}
|
|
145
|
+
function validateScope(value, field = 'scope') {
|
|
146
|
+
if (!isRecord(value))
|
|
147
|
+
fail('invalid_scope', `${field} must be an object`, 400, { field });
|
|
148
|
+
const validFrom = requiredText(value.validFrom, `${field}.validFrom`);
|
|
149
|
+
const validUntil = optionalText(value.validUntil, `${field}.validUntil`);
|
|
150
|
+
const from = parseTimestamp(validFrom, `${field}.validFrom`);
|
|
151
|
+
if (validUntil !== undefined && parseTimestamp(validUntil, `${field}.validUntil`) <= from) {
|
|
152
|
+
fail('invalid_scope', `${field}.validUntil must be after validFrom`, 400, { field });
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
subjectIds: validateUniqueIds(value.subjectIds, `${field}.subjectIds`),
|
|
156
|
+
validFrom,
|
|
157
|
+
...(validUntil === undefined ? {} : { validUntil }),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function validateDecisionReference(value, field) {
|
|
161
|
+
if (!isRecord(value) || value.type !== 'decision') {
|
|
162
|
+
fail('invalid_constraint', `${field} must reference a decision revision`, 400, { field });
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
id: requiredText(value.id, `${field}.id`),
|
|
166
|
+
type: 'decision',
|
|
167
|
+
revision: validateRevision(value.revision, `${field}.revision`),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function validateConstraintRef(value, field) {
|
|
171
|
+
if (!isRecord(value))
|
|
172
|
+
fail('invalid_constraint', `${field} must be a constraint reference`, 400, { field });
|
|
173
|
+
return {
|
|
174
|
+
id: requiredText(value.id, `${field}.id`),
|
|
175
|
+
revision: validateRevision(value.revision, `${field}.revision`),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function validateAcl(value) {
|
|
179
|
+
if (!isRecord(value))
|
|
180
|
+
fail('invalid_constraint', 'acl must be an object', 400, { field: 'acl' });
|
|
181
|
+
const visibility = value.visibility;
|
|
182
|
+
if (typeof visibility !== 'string' || !visibilityValues.has(visibility)) {
|
|
183
|
+
fail('invalid_constraint', 'acl.visibility is unsupported', 400, { field: 'acl.visibility' });
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
ownerId: requiredText(value.ownerId, 'acl.ownerId'),
|
|
187
|
+
visibility: visibility,
|
|
188
|
+
readerIds: validateUniqueIds(value.readerIds, 'acl.readerIds', { allowEmpty: true }),
|
|
189
|
+
writerIds: validateUniqueIds(value.writerIds, 'acl.writerIds', { allowEmpty: true }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function validateProvenance(value) {
|
|
193
|
+
if (!Array.isArray(value)) {
|
|
194
|
+
fail('invalid_constraint', 'provenance must be an array', 400, { field: 'provenance' });
|
|
195
|
+
}
|
|
196
|
+
return value.map((item, index) => {
|
|
197
|
+
if (!isRecord(item))
|
|
198
|
+
fail('invalid_constraint', 'provenance entry must be an object', 400, { index });
|
|
199
|
+
const sourceKind = item.sourceKind;
|
|
200
|
+
if (typeof sourceKind !== 'string' || !provenanceKinds.has(sourceKind)) {
|
|
201
|
+
fail('invalid_constraint', 'provenance.sourceKind is unsupported', 400, { index });
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
sourceId: requiredText(item.sourceId, `provenance[${index}].sourceId`),
|
|
205
|
+
sourceKind: sourceKind,
|
|
206
|
+
evidenceIds: validateUniqueIds(item.evidenceIds, `provenance[${index}].evidenceIds`, {
|
|
207
|
+
allowEmpty: true,
|
|
208
|
+
}),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function validateConstraint(record) {
|
|
213
|
+
if (!isRecord(record))
|
|
214
|
+
fail('invalid_constraint', 'constraint must be an object');
|
|
215
|
+
if (record.type !== 'constraint')
|
|
216
|
+
fail('invalid_constraint', 'constraint.type must be constraint');
|
|
217
|
+
requiredText(record.id, 'id');
|
|
218
|
+
validateRevision(record.revision);
|
|
219
|
+
requiredText(record.meaning, 'meaning');
|
|
220
|
+
if (!adoptionStates.has(record.adoptionState)) {
|
|
221
|
+
fail('invalid_constraint', 'adoptionState is unsupported', 400, { field: 'adoptionState' });
|
|
222
|
+
}
|
|
223
|
+
if (!Array.isArray(record.authorizedUses) || record.authorizedUses.length === 0) {
|
|
224
|
+
fail('invalid_constraint', 'authorizedUses must be non-empty', 400, { field: 'authorizedUses' });
|
|
225
|
+
}
|
|
226
|
+
for (const use of record.authorizedUses) {
|
|
227
|
+
if (!constraintUses.has(use)) {
|
|
228
|
+
fail('invalid_constraint', 'authorizedUses contains an unsupported value', 400, { use });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
validateAcl(record.acl);
|
|
232
|
+
if (!storageValues.has(record.storage)) {
|
|
233
|
+
fail('invalid_constraint', 'storage is unsupported', 400, { field: 'storage' });
|
|
234
|
+
}
|
|
235
|
+
validateProvenance(record.provenance);
|
|
236
|
+
validateScope(record.scope);
|
|
237
|
+
requiredText(record.condition, 'condition');
|
|
238
|
+
validateUniqueIds(record.appliesTo, 'appliesTo');
|
|
239
|
+
if (!Array.isArray(record.exceptions)) {
|
|
240
|
+
fail('invalid_constraint', 'exceptions must be an array', 400, { field: 'exceptions' });
|
|
241
|
+
}
|
|
242
|
+
record.exceptions.forEach((exception, index) => {
|
|
243
|
+
if (!isRecord(exception))
|
|
244
|
+
fail('invalid_constraint', 'exception must be an object', 400, { index });
|
|
245
|
+
validateDecisionReference(exception.decisionRef, `exceptions[${index}].decisionRef`);
|
|
246
|
+
const exceptionScope = validateScope(exception.scope, `exceptions[${index}].scope`);
|
|
247
|
+
if (!scopeIsWithin(exceptionScope, record.scope)) {
|
|
248
|
+
fail('invalid_scope', 'constraint exception scope must be non-empty and remain within the parent constraint scope', 400, { field: `exceptions[${index}].scope` });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
if (!Array.isArray(record.adoptionBasis)) {
|
|
252
|
+
fail('invalid_constraint', 'adoptionBasis must be an array', 400, { field: 'adoptionBasis' });
|
|
253
|
+
}
|
|
254
|
+
record.adoptionBasis.forEach((reference, index) => {
|
|
255
|
+
validateDecisionReference(reference, `adoptionBasis[${index}]`);
|
|
256
|
+
});
|
|
257
|
+
if (record.adoptionState === 'approved' && record.adoptionBasis.length === 0) {
|
|
258
|
+
fail('invalid_constraint', 'approved constraint requires adoptionBasis', 400, {
|
|
259
|
+
field: 'adoptionBasis',
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return record;
|
|
263
|
+
}
|
|
264
|
+
function validateContext(context) {
|
|
265
|
+
requiredText(context.actorId, 'context.actorId');
|
|
266
|
+
requiredText(context.ownerId, 'context.ownerId');
|
|
267
|
+
}
|
|
268
|
+
function validateQuery(query) {
|
|
269
|
+
requiredText(query.ownerId, 'query.ownerId');
|
|
270
|
+
parseTimestamp(query.asOf, 'query.asOf');
|
|
271
|
+
if (query.subjectId !== undefined)
|
|
272
|
+
requiredText(query.subjectId, 'query.subjectId');
|
|
273
|
+
if (query.targetId !== undefined)
|
|
274
|
+
requiredText(query.targetId, 'query.targetId');
|
|
275
|
+
if (query.use !== undefined && !constraintUses.has(query.use)) {
|
|
276
|
+
fail('invalid_constraint', 'query.use is unsupported', 400, { field: 'query.use' });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function scopeContains(scope, query) {
|
|
280
|
+
const asOf = parseTimestamp(query.asOf, 'query.asOf');
|
|
281
|
+
const from = parseTimestamp(scope.validFrom, 'scope.validFrom');
|
|
282
|
+
const until = scope.validUntil === undefined ? Number.POSITIVE_INFINITY : parseTimestamp(scope.validUntil, 'scope.validUntil');
|
|
283
|
+
if (asOf < from || asOf >= until)
|
|
284
|
+
return false;
|
|
285
|
+
if (scope.subjectIds.length === 0)
|
|
286
|
+
return false;
|
|
287
|
+
if (query.subjectId === undefined)
|
|
288
|
+
return false;
|
|
289
|
+
return scope.subjectIds.includes(query.subjectId);
|
|
290
|
+
}
|
|
291
|
+
function exceptionScopeContains(scope, query) {
|
|
292
|
+
return scopeContains(scope, query);
|
|
293
|
+
}
|
|
294
|
+
function isExceptionActive(exception, query) {
|
|
295
|
+
const expiresAt = parseTimestamp(exception.expiresAt, 'exception.expiresAt');
|
|
296
|
+
return expiresAt > parseTimestamp(query.asOf, 'query.asOf') && exceptionScopeContains(exception.scope, query);
|
|
297
|
+
}
|
|
298
|
+
function refFor(constraint) {
|
|
299
|
+
return { id: constraint.id, revision: constraint.revision };
|
|
300
|
+
}
|
|
301
|
+
function sameDecision(left, right) {
|
|
302
|
+
return left.id === right.id && left.revision === right.revision;
|
|
303
|
+
}
|
|
304
|
+
function includesDecision(references, target) {
|
|
305
|
+
return references.some((reference) => sameDecision(reference, target));
|
|
306
|
+
}
|
|
307
|
+
function defaultCompareRevisions(left, right) {
|
|
308
|
+
const parse = (value) => {
|
|
309
|
+
const match = /^(?:r)?([1-9][0-9]*)$/u.exec(value);
|
|
310
|
+
return match ? Number(match[1]) : null;
|
|
311
|
+
};
|
|
312
|
+
const leftNumber = parse(left);
|
|
313
|
+
const rightNumber = parse(right);
|
|
314
|
+
if (leftNumber !== null && rightNumber !== null)
|
|
315
|
+
return leftNumber - rightNumber;
|
|
316
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
317
|
+
}
|
|
318
|
+
export const numericConstraintRevisionSequence = {
|
|
319
|
+
next(currentRevision) {
|
|
320
|
+
const match = /^(r?)([1-9][0-9]*)$/u.exec(currentRevision);
|
|
321
|
+
if (!match) {
|
|
322
|
+
fail('unsupported_revision_sequence', 'revision sequence requires rN or N revisions', 400, {
|
|
323
|
+
currentRevision,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return `${match[1]}${Number(match[2]) + 1}`;
|
|
327
|
+
},
|
|
328
|
+
compare: defaultCompareRevisions,
|
|
329
|
+
};
|
|
330
|
+
/** In-memory adapter used by contract tests; production uses the injected canonical store. */
|
|
331
|
+
export class InMemoryConstraintStore {
|
|
332
|
+
records = new Map();
|
|
333
|
+
exceptions = new Map();
|
|
334
|
+
compareRevisions;
|
|
335
|
+
constructor(options = {}) {
|
|
336
|
+
this.compareRevisions = options.compareRevisions ?? defaultCompareRevisions;
|
|
337
|
+
}
|
|
338
|
+
async append(input) {
|
|
339
|
+
validateConstraint(input.record);
|
|
340
|
+
const current = await this.getLatest(input.record.id);
|
|
341
|
+
const expected = input.expectedPreviousRevision;
|
|
342
|
+
if (current === null) {
|
|
343
|
+
if (expected !== null) {
|
|
344
|
+
fail('revision_conflict', 'new constraint requires expectedPreviousRevision=null', 409, {
|
|
345
|
+
id: input.record.id,
|
|
346
|
+
expectedPreviousRevision: expected,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (expected === undefined || expected !== current.revision) {
|
|
351
|
+
fail('revision_conflict', 'constraint revision compare-and-swap failed', 409, {
|
|
352
|
+
id: input.record.id,
|
|
353
|
+
expectedPreviousRevision: expected,
|
|
354
|
+
currentRevision: current.revision,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const revisions = this.records.get(input.record.id) ?? new Map();
|
|
358
|
+
if (revisions.has(input.record.revision)) {
|
|
359
|
+
fail('revision_conflict', 'constraint revision already exists', 409, {
|
|
360
|
+
id: input.record.id,
|
|
361
|
+
revision: input.record.revision,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
revisions.set(input.record.revision, clone(input.record));
|
|
365
|
+
this.records.set(input.record.id, revisions);
|
|
366
|
+
return clone(input.record);
|
|
367
|
+
}
|
|
368
|
+
async get(ref) {
|
|
369
|
+
const record = this.records.get(ref.id)?.get(ref.revision);
|
|
370
|
+
return record ? clone(record) : null;
|
|
371
|
+
}
|
|
372
|
+
async getLatest(id) {
|
|
373
|
+
const revisions = this.records.get(id);
|
|
374
|
+
if (!revisions || revisions.size === 0)
|
|
375
|
+
return null;
|
|
376
|
+
let latest;
|
|
377
|
+
for (const record of revisions.values()) {
|
|
378
|
+
if (!latest || this.compareRevisions(record.revision, latest.revision) > 0)
|
|
379
|
+
latest = record;
|
|
380
|
+
}
|
|
381
|
+
return latest ? clone(latest) : null;
|
|
382
|
+
}
|
|
383
|
+
async list(query) {
|
|
384
|
+
const records = [];
|
|
385
|
+
const ids = query.id === undefined ? [...this.records.keys()] : [query.id];
|
|
386
|
+
for (const id of ids) {
|
|
387
|
+
const revisions = this.records.get(id);
|
|
388
|
+
if (!revisions)
|
|
389
|
+
continue;
|
|
390
|
+
if (query.revision !== undefined) {
|
|
391
|
+
const record = revisions.get(query.revision);
|
|
392
|
+
if (record)
|
|
393
|
+
records.push(record);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
let latest;
|
|
397
|
+
for (const record of revisions.values()) {
|
|
398
|
+
if (!latest || this.compareRevisions(record.revision, latest.revision) > 0)
|
|
399
|
+
latest = record;
|
|
400
|
+
}
|
|
401
|
+
if (latest)
|
|
402
|
+
records.push(latest);
|
|
403
|
+
}
|
|
404
|
+
return records
|
|
405
|
+
.filter((record) => query.ownerId === undefined || record.acl.ownerId === query.ownerId)
|
|
406
|
+
.map((record) => clone(record));
|
|
407
|
+
}
|
|
408
|
+
async appendException(exception) {
|
|
409
|
+
validateExceptionRecord(exception);
|
|
410
|
+
if (this.exceptions.has(exception.id)) {
|
|
411
|
+
fail('revision_conflict', 'exception id already exists', 409, { id: exception.id });
|
|
412
|
+
}
|
|
413
|
+
this.exceptions.set(exception.id, clone(exception));
|
|
414
|
+
return clone(exception);
|
|
415
|
+
}
|
|
416
|
+
async listExceptions(query) {
|
|
417
|
+
return [...this.exceptions.values()]
|
|
418
|
+
.filter((exception) => query.constraintId === undefined || exception.constraintRef.id === query.constraintId)
|
|
419
|
+
.filter((exception) => query.constraintRevision === undefined || exception.constraintRef.revision === query.constraintRevision)
|
|
420
|
+
.map((exception) => clone(exception));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function validateExceptionRecord(exception) {
|
|
424
|
+
if (!isRecord(exception))
|
|
425
|
+
fail('exception_invalid', 'exception must be an object');
|
|
426
|
+
requiredText(exception.id, 'exception.id');
|
|
427
|
+
validateConstraintRef(exception.constraintRef, 'exception.constraintRef');
|
|
428
|
+
validateDecisionReference(exception.decisionRef, 'exception.decisionRef');
|
|
429
|
+
requiredText(exception.approverId, 'exception.approverId');
|
|
430
|
+
validateScope(exception.scope, 'exception.scope');
|
|
431
|
+
parseTimestamp(exception.expiresAt, 'exception.expiresAt');
|
|
432
|
+
requiredText(exception.rationale, 'exception.rationale');
|
|
433
|
+
}
|
|
434
|
+
/** Validate a durable exception before a persistence adapter writes it. */
|
|
435
|
+
export function validateConstraintExceptionRecord(exception) {
|
|
436
|
+
validateExceptionRecord(exception);
|
|
437
|
+
}
|
|
438
|
+
function scopeIsWithin(inner, outer) {
|
|
439
|
+
if (inner.subjectIds.length === 0 || outer.subjectIds.length === 0)
|
|
440
|
+
return false;
|
|
441
|
+
if (!inner.subjectIds.every((subjectId) => outer.subjectIds.includes(subjectId)))
|
|
442
|
+
return false;
|
|
443
|
+
const innerFrom = parseTimestamp(inner.validFrom, 'exception.scope.validFrom');
|
|
444
|
+
const outerFrom = parseTimestamp(outer.validFrom, 'scope.validFrom');
|
|
445
|
+
if (innerFrom < outerFrom)
|
|
446
|
+
return false;
|
|
447
|
+
if (outer.validUntil !== undefined) {
|
|
448
|
+
const outerUntil = parseTimestamp(outer.validUntil, 'scope.validUntil');
|
|
449
|
+
const innerUntil = inner.validUntil === undefined
|
|
450
|
+
? Number.POSITIVE_INFINITY
|
|
451
|
+
: parseTimestamp(inner.validUntil, 'exception.scope.validUntil');
|
|
452
|
+
if (innerUntil > outerUntil)
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
export function createSingleOwnerConstraintAuthorization() {
|
|
458
|
+
return {
|
|
459
|
+
authorize(request) {
|
|
460
|
+
validateContext(request.context);
|
|
461
|
+
if (request.context.actorId !== request.context.ownerId) {
|
|
462
|
+
fail('authorization_denied', 'single-owner authorization requires actorId to equal ownerId', 403);
|
|
463
|
+
}
|
|
464
|
+
if (request.action === 'exception' && isRecord(request.input)) {
|
|
465
|
+
if (request.input.approverId !== request.context.actorId) {
|
|
466
|
+
fail('authorization_denied', 'single-owner authorization requires approverId to equal actorId', 403);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const targetOwner = request.ownerId ?? request.constraint?.acl.ownerId;
|
|
470
|
+
if (targetOwner !== undefined && targetOwner !== request.context.ownerId) {
|
|
471
|
+
fail('scope_violation', 'constraint owner is outside the authorization context', 403, {
|
|
472
|
+
ownerId: targetOwner,
|
|
473
|
+
contextOwnerId: request.context.ownerId,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
export class ConstraintService {
|
|
480
|
+
options;
|
|
481
|
+
revisionSequence;
|
|
482
|
+
constructor(options) {
|
|
483
|
+
this.options = options;
|
|
484
|
+
this.revisionSequence = options.revisionSequence ?? numericConstraintRevisionSequence;
|
|
485
|
+
}
|
|
486
|
+
async createConstraint(record, context) {
|
|
487
|
+
validateContext(context);
|
|
488
|
+
validateConstraint(record);
|
|
489
|
+
if (record.acl.ownerId !== context.ownerId) {
|
|
490
|
+
fail('scope_violation', 'constraint owner is outside the authorization context', 403);
|
|
491
|
+
}
|
|
492
|
+
await this.authorize({
|
|
493
|
+
action: 'create',
|
|
494
|
+
context,
|
|
495
|
+
ownerId: record.acl.ownerId,
|
|
496
|
+
input: record,
|
|
497
|
+
});
|
|
498
|
+
return this.appendAndReadback(record, null, context);
|
|
499
|
+
}
|
|
500
|
+
async getConstraint(ref, context) {
|
|
501
|
+
validateContext(context);
|
|
502
|
+
validateConstraintRef(ref, 'constraintRef');
|
|
503
|
+
const record = await this.options.store.get(ref, context);
|
|
504
|
+
await this.authorize({ action: 'read', context, ownerId: record?.acl.ownerId ?? context.ownerId, constraint: record });
|
|
505
|
+
if (!record)
|
|
506
|
+
return null;
|
|
507
|
+
if (record.acl.ownerId !== context.ownerId) {
|
|
508
|
+
fail('scope_violation', 'constraint owner is outside the authorization context', 403);
|
|
509
|
+
}
|
|
510
|
+
return record;
|
|
511
|
+
}
|
|
512
|
+
async updateConstraint(id, expectedRevision, patch, context) {
|
|
513
|
+
validateContext(context);
|
|
514
|
+
requiredText(id, 'id');
|
|
515
|
+
validateRevision(expectedRevision, 'expectedRevision');
|
|
516
|
+
const current = await this.requireLatest(id, context);
|
|
517
|
+
this.assertOwner(current, context);
|
|
518
|
+
await this.authorize({ action: 'update', context, ownerId: current.acl.ownerId, constraint: current, input: patch });
|
|
519
|
+
if (current.revision !== expectedRevision) {
|
|
520
|
+
fail('revision_conflict', 'expected constraint revision is stale', 409, {
|
|
521
|
+
id,
|
|
522
|
+
expectedRevision,
|
|
523
|
+
currentRevision: current.revision,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (!isRecord(patch))
|
|
527
|
+
fail('invalid_constraint', 'constraint patch must be an object');
|
|
528
|
+
const nextRevision = this.revisionSequence.next(current.revision);
|
|
529
|
+
const next = {
|
|
530
|
+
...current,
|
|
531
|
+
...patch,
|
|
532
|
+
id: current.id,
|
|
533
|
+
type: 'constraint',
|
|
534
|
+
revision: nextRevision,
|
|
535
|
+
};
|
|
536
|
+
if (next.acl.ownerId !== current.acl.ownerId) {
|
|
537
|
+
fail('scope_violation', 'constraint owner cannot change across revisions', 403);
|
|
538
|
+
}
|
|
539
|
+
validateConstraint(next);
|
|
540
|
+
return this.appendAndReadback(next, expectedRevision, context);
|
|
541
|
+
}
|
|
542
|
+
async adoptConstraint(id, expectedRevision, decisionRef, context) {
|
|
543
|
+
validateContext(context);
|
|
544
|
+
requiredText(id, 'id');
|
|
545
|
+
validateRevision(expectedRevision, 'expectedRevision');
|
|
546
|
+
const validatedDecision = validateDecisionReference(decisionRef, 'decisionRef');
|
|
547
|
+
const current = await this.requireLatest(id, context);
|
|
548
|
+
this.assertOwner(current, context);
|
|
549
|
+
await this.authorize({
|
|
550
|
+
action: 'adopt',
|
|
551
|
+
context,
|
|
552
|
+
ownerId: current.acl.ownerId,
|
|
553
|
+
constraint: current,
|
|
554
|
+
input: validatedDecision,
|
|
555
|
+
});
|
|
556
|
+
if (current.revision !== expectedRevision) {
|
|
557
|
+
fail('revision_conflict', 'expected constraint revision is stale', 409, {
|
|
558
|
+
id,
|
|
559
|
+
expectedRevision,
|
|
560
|
+
currentRevision: current.revision,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
let decisionExists = false;
|
|
564
|
+
try {
|
|
565
|
+
decisionExists = await this.options.decisionReader.exists(validatedDecision, context);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
decisionExists = false;
|
|
569
|
+
}
|
|
570
|
+
if (!decisionExists) {
|
|
571
|
+
fail('decision_reference_unresolved', 'adoption decision revision could not be resolved', 409, {
|
|
572
|
+
decisionRef: validatedDecision,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
const next = {
|
|
576
|
+
...current,
|
|
577
|
+
revision: this.revisionSequence.next(current.revision),
|
|
578
|
+
adoptionState: 'approved',
|
|
579
|
+
adoptionBasis: includesDecision(current.adoptionBasis, validatedDecision)
|
|
580
|
+
? current.adoptionBasis
|
|
581
|
+
: [...current.adoptionBasis, validatedDecision],
|
|
582
|
+
};
|
|
583
|
+
validateConstraint(next);
|
|
584
|
+
return this.appendAndReadback(next, expectedRevision, context);
|
|
585
|
+
}
|
|
586
|
+
async registerException(exception, context) {
|
|
587
|
+
validateContext(context);
|
|
588
|
+
validateExceptionRecord(exception);
|
|
589
|
+
const target = await this.options.store.get(exception.constraintRef, context);
|
|
590
|
+
if (!target)
|
|
591
|
+
fail('constraint_not_found', 'constraint revision for exception was not found', 404, {
|
|
592
|
+
constraintRef: exception.constraintRef,
|
|
593
|
+
});
|
|
594
|
+
this.assertOwner(target, context);
|
|
595
|
+
if (!scopeIsWithin(exception.scope, target.scope)) {
|
|
596
|
+
fail('exception_invalid', 'exception scope must be contained by constraint scope', 400, {
|
|
597
|
+
constraintRef: exception.constraintRef,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (parseTimestamp(exception.expiresAt, 'exception.expiresAt') <= parseTimestamp(exception.scope.validFrom, 'exception.scope.validFrom')) {
|
|
601
|
+
fail('exception_invalid', 'exception expiresAt must be after scope.validFrom', 400);
|
|
602
|
+
}
|
|
603
|
+
await this.authorize({
|
|
604
|
+
action: 'exception',
|
|
605
|
+
context,
|
|
606
|
+
ownerId: target.acl.ownerId,
|
|
607
|
+
constraint: target,
|
|
608
|
+
input: exception,
|
|
609
|
+
});
|
|
610
|
+
let decisionExists = false;
|
|
611
|
+
try {
|
|
612
|
+
decisionExists = await this.options.decisionReader.exists(exception.decisionRef, context);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
decisionExists = false;
|
|
616
|
+
}
|
|
617
|
+
if (!decisionExists) {
|
|
618
|
+
fail('decision_reference_unresolved', 'exception decision revision could not be resolved', 409, {
|
|
619
|
+
decisionRef: exception.decisionRef,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
const stored = await this.options.store.appendException(clone(exception), context);
|
|
624
|
+
if (!isDeepStrictEqual(stored, exception)) {
|
|
625
|
+
fail('constraint_store_corrupt', 'exception store readback payload did not match', 500, {
|
|
626
|
+
id: exception.id,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return stored;
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (error instanceof ConstraintError)
|
|
633
|
+
throw error;
|
|
634
|
+
fail('constraint_store_unavailable', 'exception store failed', 503);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async resolveConstraints(query, context) {
|
|
638
|
+
validateContext(context);
|
|
639
|
+
validateQuery(query);
|
|
640
|
+
const unresolvedReasons = [];
|
|
641
|
+
const executionAuthority = 'not_granted';
|
|
642
|
+
try {
|
|
643
|
+
await this.authorize({ action: 'resolve', context, ownerId: query.ownerId, input: query });
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
const issue = this.authorizationIssue(error);
|
|
647
|
+
return { status: 'unresolved', constraints: [], unresolvedReasons: [issue], executionAuthority };
|
|
648
|
+
}
|
|
649
|
+
if (query.ownerId !== context.ownerId) {
|
|
650
|
+
return {
|
|
651
|
+
status: 'unresolved',
|
|
652
|
+
constraints: [],
|
|
653
|
+
unresolvedReasons: [{ code: 'scope_violation', message: 'query owner is outside the authorization context' }],
|
|
654
|
+
executionAuthority,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
let candidates;
|
|
658
|
+
try {
|
|
659
|
+
candidates = await this.options.store.list({ ownerId: query.ownerId }, context);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return {
|
|
663
|
+
status: 'unresolved',
|
|
664
|
+
constraints: [],
|
|
665
|
+
unresolvedReasons: [{ code: 'constraint_store_unavailable', message: 'constraint store could not be read' }],
|
|
666
|
+
executionAuthority,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const applicable = [];
|
|
670
|
+
for (const candidate of candidates) {
|
|
671
|
+
const ref = refFor(candidate);
|
|
672
|
+
if (candidate.acl.ownerId !== query.ownerId) {
|
|
673
|
+
unresolvedReasons.push({
|
|
674
|
+
code: 'scope_violation',
|
|
675
|
+
message: 'constraint returned outside query owner scope',
|
|
676
|
+
constraintRef: ref,
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (candidate.scope.subjectIds.length === 0 || query.subjectId === undefined) {
|
|
681
|
+
unresolvedReasons.push({
|
|
682
|
+
code: 'scope_unknown',
|
|
683
|
+
message: 'constraint subject scope cannot be established for this query',
|
|
684
|
+
constraintRef: ref,
|
|
685
|
+
});
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (!scopeContains(candidate.scope, query))
|
|
689
|
+
continue;
|
|
690
|
+
if (query.targetId === undefined) {
|
|
691
|
+
unresolvedReasons.push({
|
|
692
|
+
code: 'target_scope_unknown',
|
|
693
|
+
message: 'constraint target scope cannot be established without targetId',
|
|
694
|
+
constraintRef: ref,
|
|
695
|
+
});
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (!candidate.appliesTo.includes(query.targetId))
|
|
699
|
+
continue;
|
|
700
|
+
if (candidate.adoptionState === 'retired') {
|
|
701
|
+
unresolvedReasons.push({ code: 'retired_constraint', message: 'retired constraint cannot be applied', constraintRef: ref });
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const requestedUse = query.use ?? 'judgment';
|
|
705
|
+
if (!candidate.authorizedUses.includes(requestedUse)) {
|
|
706
|
+
unresolvedReasons.push({
|
|
707
|
+
code: 'unauthorized_use',
|
|
708
|
+
message: `constraint is not authorized for ${requestedUse} use`,
|
|
709
|
+
constraintRef: ref,
|
|
710
|
+
});
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (candidate.adoptionState !== 'approved' || candidate.adoptionBasis.length === 0) {
|
|
714
|
+
unresolvedReasons.push({
|
|
715
|
+
code: 'decision_reference_missing',
|
|
716
|
+
message: 'constraint has no approved decision basis',
|
|
717
|
+
constraintRef: ref,
|
|
718
|
+
});
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
let decisionUnresolved = false;
|
|
722
|
+
for (const decisionRef of candidate.adoptionBasis) {
|
|
723
|
+
let decisionExists = false;
|
|
724
|
+
try {
|
|
725
|
+
decisionExists = await this.options.decisionReader.exists(decisionRef, context);
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
decisionExists = false;
|
|
729
|
+
}
|
|
730
|
+
if (!decisionExists) {
|
|
731
|
+
unresolvedReasons.push({
|
|
732
|
+
code: 'decision_reference_unresolved',
|
|
733
|
+
message: 'constraint adoption decision revision could not be resolved',
|
|
734
|
+
constraintRef: ref,
|
|
735
|
+
});
|
|
736
|
+
decisionUnresolved = true;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (decisionUnresolved)
|
|
740
|
+
continue;
|
|
741
|
+
let activeExceptions;
|
|
742
|
+
try {
|
|
743
|
+
activeExceptions = (await this.options.store.listExceptions({
|
|
744
|
+
constraintId: candidate.id,
|
|
745
|
+
constraintRevision: candidate.revision,
|
|
746
|
+
}, context)).filter((exception) => {
|
|
747
|
+
try {
|
|
748
|
+
validateExceptionRecord(exception);
|
|
749
|
+
return isExceptionActive(exception, query);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
unresolvedReasons.push({
|
|
753
|
+
code: 'exception_invalid',
|
|
754
|
+
message: 'exception timestamp or scope could not be evaluated',
|
|
755
|
+
constraintRef: ref,
|
|
756
|
+
});
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
unresolvedReasons.push({
|
|
763
|
+
code: 'exception_store_unavailable',
|
|
764
|
+
message: 'exception store could not be read',
|
|
765
|
+
constraintRef: ref,
|
|
766
|
+
});
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
let exceptionDecisionUnresolved = false;
|
|
770
|
+
for (const exception of activeExceptions) {
|
|
771
|
+
let decisionExists = false;
|
|
772
|
+
try {
|
|
773
|
+
decisionExists = await this.options.decisionReader.exists(exception.decisionRef, context);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
decisionExists = false;
|
|
777
|
+
}
|
|
778
|
+
if (!decisionExists) {
|
|
779
|
+
unresolvedReasons.push({
|
|
780
|
+
code: 'decision_reference_unresolved',
|
|
781
|
+
message: 'exception decision revision could not be resolved',
|
|
782
|
+
constraintRef: ref,
|
|
783
|
+
});
|
|
784
|
+
exceptionDecisionUnresolved = true;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (exceptionDecisionUnresolved)
|
|
788
|
+
continue;
|
|
789
|
+
let exceptionAuthorizationUnresolved = false;
|
|
790
|
+
for (const exception of activeExceptions) {
|
|
791
|
+
try {
|
|
792
|
+
await this.authorize({
|
|
793
|
+
action: 'exception',
|
|
794
|
+
context,
|
|
795
|
+
ownerId: candidate.acl.ownerId,
|
|
796
|
+
constraint: candidate,
|
|
797
|
+
input: exception,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
unresolvedReasons.push({
|
|
802
|
+
code: 'exception_invalid',
|
|
803
|
+
message: 'exception approval could not be authorized for this resolution',
|
|
804
|
+
constraintRef: ref,
|
|
805
|
+
});
|
|
806
|
+
exceptionAuthorizationUnresolved = true;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (exceptionAuthorizationUnresolved)
|
|
810
|
+
continue;
|
|
811
|
+
applicable.push(candidate);
|
|
812
|
+
try {
|
|
813
|
+
const evaluation = await this.options.evaluator.evaluate({
|
|
814
|
+
constraint: candidate,
|
|
815
|
+
candidates,
|
|
816
|
+
query,
|
|
817
|
+
activeExceptions,
|
|
818
|
+
});
|
|
819
|
+
if (!isEvaluationResult(evaluation)) {
|
|
820
|
+
unresolvedReasons.push({
|
|
821
|
+
code: 'evaluation_invalid',
|
|
822
|
+
message: 'constraint evaluator returned an invalid result',
|
|
823
|
+
constraintRef: ref,
|
|
824
|
+
});
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (evaluation.status === 'unknown') {
|
|
828
|
+
unresolvedReasons.push({
|
|
829
|
+
code: 'evaluation_unknown',
|
|
830
|
+
message: evaluation.reason,
|
|
831
|
+
constraintRef: ref,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
else if (evaluation.status === 'conflict') {
|
|
835
|
+
unresolvedReasons.push({
|
|
836
|
+
code: 'constraint_conflict',
|
|
837
|
+
message: evaluation.reason,
|
|
838
|
+
constraintRef: ref,
|
|
839
|
+
conflictingRefs: evaluation.conflictingRefs,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
else if (!evaluation.applies) {
|
|
843
|
+
applicable.pop();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
unresolvedReasons.push({
|
|
848
|
+
code: 'evaluation_unavailable',
|
|
849
|
+
message: 'constraint evaluator failed closed',
|
|
850
|
+
constraintRef: ref,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
status: unresolvedReasons.length === 0 ? 'resolved' : 'unresolved',
|
|
856
|
+
constraints: unresolvedReasons.length === 0 ? applicable : [],
|
|
857
|
+
unresolvedReasons,
|
|
858
|
+
executionAuthority,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
async requireLatest(id, context) {
|
|
862
|
+
let current;
|
|
863
|
+
try {
|
|
864
|
+
current = await this.options.store.getLatest(id, context);
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
fail('constraint_store_unavailable', 'constraint store could not be read', 503, { id });
|
|
868
|
+
}
|
|
869
|
+
if (!current)
|
|
870
|
+
fail('constraint_not_found', 'constraint was not found', 404, { id });
|
|
871
|
+
return current;
|
|
872
|
+
}
|
|
873
|
+
assertOwner(record, context) {
|
|
874
|
+
if (record.acl.ownerId !== context.ownerId) {
|
|
875
|
+
fail('scope_violation', 'constraint owner is outside the authorization context', 403);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
async authorize(request) {
|
|
879
|
+
try {
|
|
880
|
+
await this.options.authorization.authorize(request);
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
if (error instanceof ConstraintError)
|
|
884
|
+
throw error;
|
|
885
|
+
fail('authorization_denied', 'constraint authorization was denied', 403);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
authorizationIssue(error) {
|
|
889
|
+
if (error instanceof ConstraintError && error.code === 'scope_violation') {
|
|
890
|
+
return { code: 'scope_violation', message: error.message };
|
|
891
|
+
}
|
|
892
|
+
return { code: 'scope_violation', message: 'constraint authorization could not be established' };
|
|
893
|
+
}
|
|
894
|
+
async appendAndReadback(record, expectedPreviousRevision, context) {
|
|
895
|
+
let stored;
|
|
896
|
+
try {
|
|
897
|
+
stored = await this.options.store.append({ record: clone(record), expectedPreviousRevision }, context);
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
if (error instanceof ConstraintError)
|
|
901
|
+
throw error;
|
|
902
|
+
fail('constraint_store_unavailable', 'constraint store append failed', 503, { id: record.id });
|
|
903
|
+
}
|
|
904
|
+
const readback = await this.options.store.get({ id: record.id, revision: record.revision }, context);
|
|
905
|
+
if (!readback || JSON.stringify(readback) !== JSON.stringify(stored) || JSON.stringify(readback) !== JSON.stringify(record)) {
|
|
906
|
+
fail('constraint_store_corrupt', 'constraint store readback did not match append result', 500, {
|
|
907
|
+
id: record.id,
|
|
908
|
+
revision: record.revision,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return readback;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
function isEvaluationResult(value) {
|
|
915
|
+
if (!isRecord(value) || typeof value.status !== 'string')
|
|
916
|
+
return false;
|
|
917
|
+
if (value.status === 'resolved')
|
|
918
|
+
return typeof value.applies === 'boolean';
|
|
919
|
+
if (value.status === 'unknown' || value.status === 'conflict')
|
|
920
|
+
return typeof value.reason === 'string' && value.reason.length > 0;
|
|
921
|
+
return false;
|
|
922
|
+
}
|