@gnolith/taproot 0.1.0-rc.0 → 0.3.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/CHANGELOG.md +63 -0
- package/COMPATIBILITY.md +15 -3
- package/LICENSE +21 -21
- package/README.md +122 -46
- package/SECURITY.md +10 -3
- package/SUPPORT.md +6 -1
- package/dist/authorization-maintenance.d.ts +55 -0
- package/dist/authorization-maintenance.d.ts.map +1 -0
- package/dist/authorization-maintenance.js +686 -0
- package/dist/authorization-maintenance.js.map +1 -0
- package/dist/authorization.d.ts +98 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/authorization.js +1137 -0
- package/dist/authorization.js.map +1 -0
- package/dist/canonical.d.ts +2 -1
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +9 -1
- package/dist/canonical.js.map +1 -1
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +127 -50
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +352 -44
- package/dist/index.js.map +1 -1
- package/dist/migrations.d.ts +42 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +550 -0
- package/dist/migrations.js.map +1 -0
- package/dist/rdf.js +1 -1
- package/dist/rdf.js.map +1 -1
- package/dist/repository.d.ts +22 -11
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +596 -63
- package/dist/repository.js.map +1 -1
- package/dist/schema.d.ts +41 -7
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +722 -83
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/api.md +70 -18
- package/docs/architecture.md +21 -8
- package/docs/authorization.md +82 -0
- package/docs/completion-audit.md +22 -16
- package/docs/operations.md +48 -9
- package/docs/product-scope.md +11 -4
- package/docs/release-policy.md +4 -0
- package/docs/testing.md +31 -5
- package/docs/threat-model.md +38 -6
- package/examples/d1-diamond-interop/README.md +12 -0
- package/examples/d1-diamond-interop/demo.ts +281 -0
- package/migrations/0002_audit_operations.sql +4 -3
- package/migrations/0003_canonical_statement_text.sql +31 -0
- package/migrations/0004_canonical_authorization_policy.sql +213 -0
- package/package.json +12 -7
- package/examples/codex-site/.openai/hosting.json +0 -3
- package/examples/codex-site/README.md +0 -13
- package/examples/codex-site/demo.ts +0 -152
package/dist/repository.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { QuadPatchConflictError, encodeTerm, decodeTerm, prepareQuadPatch, } from '@gnolith/diamond';
|
|
2
2
|
import { DataFactory } from 'rdf-data-factory';
|
|
3
|
-
import { cloneEntity, entityNumericId, exportEntityJson, MAX_ENTITY_BYTES, parseEntityJson, validateEntity, validateSnak, } from './canonical.js';
|
|
4
|
-
import { EntityAlreadyExistsError, EntityNotFoundError, InvalidEntityError, InvalidStatementError, InvalidCursorError, BulkLimitError, PropertyDatatypeMismatchError, PropertyNotFoundError, QuadPatchTooLargeError, RevisionConflictError, SchemaMismatchError, } from './errors.js';
|
|
3
|
+
import { cloneEntity, assertAuthoredStatementText, entityNumericId, exportEntityJson, MAX_ENTITY_BYTES, parseEntityJson, validateEntity, validateSnak, } from './canonical.js';
|
|
4
|
+
import { EntityAlreadyExistsError, AuthorizationDeniedError, EntityNotFoundError, InvalidEntityError, InvalidStatementError, InvalidCursorError, BulkLimitError, PropertyDatatypeMismatchError, PropertyNotFoundError, QuadPatchTooLargeError, RevisionConflictError, SchemaMismatchError, } from './errors.js';
|
|
5
5
|
import { buildEntityQuads } from './rdf.js';
|
|
6
6
|
import { withoutTrailingSlashes } from './iri.js';
|
|
7
|
+
import { canonicalizeTaprootBaseIri } from './migrations.js';
|
|
7
8
|
import { TAPROOT_RDF_VERSION } from './schema.js';
|
|
9
|
+
import { intersectVisibilityScopes, isVisibleTo, normalizeCanonicalAuthorizationPolicy, normalizeAuthorizationContext, normalizeVisibilityScope, serializeVisibilityScope, KNOWLEDGE_POLICY_CAPABILITY, KNOWLEDGE_WRITE_CAPABILITY, } from './authorization.js';
|
|
10
|
+
/** Package-internal persistence implementation. Not exported by the package. */
|
|
8
11
|
export class TaprootRepository {
|
|
9
12
|
#db;
|
|
10
13
|
#options;
|
|
@@ -13,19 +16,10 @@ export class TaprootRepository {
|
|
|
13
16
|
options.mappingVersion !== TAPROOT_RDF_VERSION) {
|
|
14
17
|
throw new SchemaMismatchError(`RDF mapping version ${options.mappingVersion} is not supported`);
|
|
15
18
|
}
|
|
16
|
-
|
|
17
|
-
const base = new URL(options.baseIri);
|
|
18
|
-
if (base.protocol !== 'http:' && base.protocol !== 'https:')
|
|
19
|
-
throw new TypeError();
|
|
20
|
-
}
|
|
21
|
-
catch (cause) {
|
|
22
|
-
throw new InvalidEntityError('baseIri must be an absolute HTTP(S) IRI', {
|
|
23
|
-
cause,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
19
|
+
const baseIri = canonicalizeTaprootBaseIri(options.baseIri);
|
|
26
20
|
this.#db = db;
|
|
27
21
|
this.#options = {
|
|
28
|
-
baseIri
|
|
22
|
+
baseIri,
|
|
29
23
|
mappingVersion: options.mappingVersion ?? TAPROOT_RDF_VERSION,
|
|
30
24
|
maxEntityBytes: options.maxEntityBytes ?? MAX_ENTITY_BYTES,
|
|
31
25
|
maxBulkEntities: options.maxBulkEntities ?? 100,
|
|
@@ -37,6 +31,79 @@ export class TaprootRepository {
|
|
|
37
31
|
...(options.factory ? { factory: options.factory } : {}),
|
|
38
32
|
};
|
|
39
33
|
}
|
|
34
|
+
/** Assembly-only pristine bootstrap. Canonical writes remain policy-bound. */
|
|
35
|
+
async bootstrapAuthorization(installationId) {
|
|
36
|
+
if (typeof installationId !== 'string' ||
|
|
37
|
+
!installationId.trim() ||
|
|
38
|
+
installationId.length > 256)
|
|
39
|
+
throw new InvalidEntityError('installationId must contain 1 through 256 characters');
|
|
40
|
+
const createdAt = this.#options.clock().toISOString();
|
|
41
|
+
try {
|
|
42
|
+
await this.#db.batch([
|
|
43
|
+
this.#assertion(`NOT EXISTS (SELECT 1 FROM taproot_entities)`),
|
|
44
|
+
this.#db
|
|
45
|
+
.prepare(`INSERT INTO taproot_installation_authorization(
|
|
46
|
+
singleton, installation_id, authorization_revision,
|
|
47
|
+
search_generation, last_advance_id, created_at, updated_at
|
|
48
|
+
) VALUES (1, ?, 1, 1, 'bootstrap', ?, ?)`)
|
|
49
|
+
.bind(installationId, createdAt, createdAt),
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
catch (cause) {
|
|
53
|
+
throw new RevisionConflictError('Authorization bootstrap requires a pristine uninitialized installation', { cause: toError(cause) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Explicit bootstrap for a fully quarantined pre-0004 installation. */
|
|
57
|
+
async bootstrapLegacyAuthorization(installationId, attestation) {
|
|
58
|
+
if (typeof installationId !== 'string' ||
|
|
59
|
+
!installationId.trim() ||
|
|
60
|
+
installationId.length > 256)
|
|
61
|
+
throw new InvalidEntityError('installationId must contain 1 through 256 characters');
|
|
62
|
+
if (!Number.isSafeInteger(attestation.entityCount) ||
|
|
63
|
+
attestation.entityCount < 1 ||
|
|
64
|
+
!Number.isSafeInteger(attestation.revisionCount) ||
|
|
65
|
+
attestation.revisionCount < attestation.entityCount ||
|
|
66
|
+
!/^[a-f0-9]{64}$/u.test(attestation.revisionManifestHash))
|
|
67
|
+
throw new InvalidEntityError('Legacy authorization attestation is invalid');
|
|
68
|
+
const counts = await this.#db
|
|
69
|
+
.prepare(`SELECT
|
|
70
|
+
(SELECT COUNT(*) FROM taproot_entities) AS entity_count,
|
|
71
|
+
(SELECT COUNT(*) FROM taproot_entity_revisions) AS revision_count,
|
|
72
|
+
(SELECT COUNT(*) FROM taproot_entity_authorization) AS policy_count`)
|
|
73
|
+
.all();
|
|
74
|
+
const count = counts.results[0];
|
|
75
|
+
const revisions = await this.#db
|
|
76
|
+
.prepare(`SELECT entity_id, revision, content_hash
|
|
77
|
+
FROM taproot_entity_revisions ORDER BY entity_id, revision`)
|
|
78
|
+
.all();
|
|
79
|
+
const manifestHash = await sha256(JSON.stringify(revisions.results.map(({ entity_id, revision, content_hash }) => [
|
|
80
|
+
entity_id,
|
|
81
|
+
Number(revision),
|
|
82
|
+
content_hash,
|
|
83
|
+
])));
|
|
84
|
+
if (Number(count?.entity_count ?? 0) !== attestation.entityCount ||
|
|
85
|
+
Number(count?.revision_count ?? 0) !== attestation.revisionCount ||
|
|
86
|
+
Number(count?.policy_count ?? 0) !== 0 ||
|
|
87
|
+
manifestHash !== attestation.revisionManifestHash)
|
|
88
|
+
throw new RevisionConflictError('Legacy authorization attestation does not match quarantined canonical state');
|
|
89
|
+
const createdAt = this.#options.clock().toISOString();
|
|
90
|
+
try {
|
|
91
|
+
await this.#db.batch([
|
|
92
|
+
this.#assertion(`(SELECT COUNT(*) FROM taproot_entities) = ?
|
|
93
|
+
AND (SELECT COUNT(*) FROM taproot_entity_revisions) = ?
|
|
94
|
+
AND NOT EXISTS (SELECT 1 FROM taproot_entity_authorization)`, attestation.entityCount, attestation.revisionCount),
|
|
95
|
+
this.#db
|
|
96
|
+
.prepare(`INSERT INTO taproot_installation_authorization(
|
|
97
|
+
singleton, installation_id, authorization_revision,
|
|
98
|
+
search_generation, last_advance_id, created_at, updated_at
|
|
99
|
+
) VALUES (1, ?, 1, 1, 'legacy-bootstrap', ?, ?)`)
|
|
100
|
+
.bind(installationId, createdAt, createdAt),
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
throw new RevisionConflictError('Legacy authorization bootstrap lost its state attestation race', { cause: toError(cause) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
40
107
|
async getEntity(id) {
|
|
41
108
|
const row = await this.#loadRow(id);
|
|
42
109
|
if (!row)
|
|
@@ -273,11 +340,15 @@ export class TaprootRepository {
|
|
|
273
340
|
async replaceEntity(id, replacement, edit) {
|
|
274
341
|
if (replacement.id !== id)
|
|
275
342
|
throw new InvalidEntityError('Replacement entity id cannot change');
|
|
276
|
-
return this.#mutate(id, edit, () => cloneEntity(replacement), undefined, 'update');
|
|
343
|
+
return this.#mutate(id, edit, () => resupplyStatementTexts(cloneEntity(replacement), edit.statementTexts), undefined, 'update');
|
|
277
344
|
}
|
|
278
345
|
async revertEntity(id, targetRevision, edit) {
|
|
346
|
+
if (edit.authorization || edit.authorizationContext)
|
|
347
|
+
await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
|
|
279
348
|
const target = await this.getEntityRevision(id, targetRevision);
|
|
280
|
-
|
|
349
|
+
if (target.redirectTo)
|
|
350
|
+
await this.#validateRedirectTarget(id, target.redirectTo, target.entity.type, edit.authorizationContext);
|
|
351
|
+
return this.#mutate(id, edit, () => resupplyStatementTexts(cloneEntity(target.entity), edit.statementTexts), { deletedAt: target.deletedAt, redirectTo: target.redirectTo }, 'revert');
|
|
281
352
|
}
|
|
282
353
|
async softDeleteEntity(id, edit) {
|
|
283
354
|
return this.#mutate(id, edit, (entity) => entity, {
|
|
@@ -294,22 +365,16 @@ export class TaprootRepository {
|
|
|
294
365
|
async redirectEntity(id, target, edit) {
|
|
295
366
|
if (id === target)
|
|
296
367
|
throw new InvalidEntityError('An entity cannot redirect to itself');
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
throw new InvalidEntityError('Redirect would create a cycle');
|
|
308
|
-
seen.add(cursor);
|
|
309
|
-
cursor = (await this.getEntity(cursor)).redirectTo;
|
|
310
|
-
}
|
|
311
|
-
if (cursor)
|
|
312
|
-
throw new InvalidEntityError('Redirect chain exceeds 100 entities');
|
|
368
|
+
if (edit.authorization || edit.authorizationContext)
|
|
369
|
+
await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
|
|
370
|
+
const source = edit.authorizationContext
|
|
371
|
+
? await this.#authorizedLifecycle(id, edit.authorizationContext)
|
|
372
|
+
: await this.getEntity(id).then(({ entity, deletedAt, redirectTo }) => ({
|
|
373
|
+
type: entity.type,
|
|
374
|
+
deletedAt,
|
|
375
|
+
redirectTo,
|
|
376
|
+
}));
|
|
377
|
+
await this.#validateRedirectTarget(id, target, source.type, edit.authorizationContext);
|
|
313
378
|
return this.#mutate(id, edit, (entity) => entity, {
|
|
314
379
|
deletedAt: null,
|
|
315
380
|
redirectTo: target,
|
|
@@ -374,6 +439,7 @@ export class TaprootRepository {
|
|
|
374
439
|
});
|
|
375
440
|
}
|
|
376
441
|
async addStatement(id, statement, edit) {
|
|
442
|
+
assertAuthoredStatementText(statement.text, statement.id);
|
|
377
443
|
return this.#mutate(id, edit, (entity) => {
|
|
378
444
|
const property = statement.mainsnak.property;
|
|
379
445
|
entity.claims[property] ??= [];
|
|
@@ -382,6 +448,7 @@ export class TaprootRepository {
|
|
|
382
448
|
});
|
|
383
449
|
}
|
|
384
450
|
async replaceStatement(id, statementId, statement, edit) {
|
|
451
|
+
assertAuthoredStatementText(statement.text, statement.id);
|
|
385
452
|
return this.#mutate(id, edit, (entity) => {
|
|
386
453
|
const located = locateStatement(entity, statementId);
|
|
387
454
|
located.statements.splice(located.index, 1);
|
|
@@ -402,16 +469,21 @@ export class TaprootRepository {
|
|
|
402
469
|
return entity;
|
|
403
470
|
});
|
|
404
471
|
}
|
|
405
|
-
async setStatementRank(id, statementId, rank, edit) {
|
|
472
|
+
async setStatementRank(id, statementId, rank, text, edit) {
|
|
473
|
+
assertAuthoredStatementText(text, statementId);
|
|
406
474
|
return this.#mutate(id, edit, (entity) => {
|
|
407
|
-
locateStatement(entity, statementId).statement
|
|
475
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
476
|
+
statement.rank = rank;
|
|
477
|
+
statement.text = text;
|
|
408
478
|
return entity;
|
|
409
479
|
});
|
|
410
480
|
}
|
|
411
|
-
async addQualifier(id, statementId, snak, edit) {
|
|
481
|
+
async addQualifier(id, statementId, snak, text, edit) {
|
|
482
|
+
assertAuthoredStatementText(text, statementId);
|
|
412
483
|
validateSnak(snak);
|
|
413
484
|
return this.#mutate(id, edit, (entity) => {
|
|
414
485
|
const statement = locateStatement(entity, statementId).statement;
|
|
486
|
+
statement.text = text;
|
|
415
487
|
const property = snak.property;
|
|
416
488
|
if (!statement.qualifiers[property]) {
|
|
417
489
|
statement.qualifiers[property] = [];
|
|
@@ -421,9 +493,11 @@ export class TaprootRepository {
|
|
|
421
493
|
return entity;
|
|
422
494
|
});
|
|
423
495
|
}
|
|
424
|
-
async removeQualifier(id, statementId, property, ordinal, edit) {
|
|
496
|
+
async removeQualifier(id, statementId, property, ordinal, text, edit) {
|
|
497
|
+
assertAuthoredStatementText(text, statementId);
|
|
425
498
|
return this.#mutate(id, edit, (entity) => {
|
|
426
499
|
const statement = locateStatement(entity, statementId).statement;
|
|
500
|
+
statement.text = text;
|
|
427
501
|
const snaks = statement.qualifiers[property];
|
|
428
502
|
if (!snaks?.[ordinal])
|
|
429
503
|
throw new InvalidStatementError('Qualifier does not exist');
|
|
@@ -435,16 +509,21 @@ export class TaprootRepository {
|
|
|
435
509
|
return entity;
|
|
436
510
|
});
|
|
437
511
|
}
|
|
438
|
-
async addReference(id, statementId, reference, edit) {
|
|
512
|
+
async addReference(id, statementId, reference, text, edit) {
|
|
513
|
+
assertAuthoredStatementText(text, statementId);
|
|
439
514
|
return this.#mutate(id, edit, (entity) => {
|
|
440
|
-
locateStatement(entity, statementId).statement
|
|
515
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
516
|
+
statement.text = text;
|
|
517
|
+
statement.references.push(structuredClone(reference));
|
|
441
518
|
return entity;
|
|
442
519
|
});
|
|
443
520
|
}
|
|
444
|
-
async removeReference(id, statementId, hash, edit) {
|
|
521
|
+
async removeReference(id, statementId, hash, text, edit) {
|
|
522
|
+
assertAuthoredStatementText(text, statementId);
|
|
445
523
|
return this.#mutate(id, edit, (entity) => {
|
|
446
|
-
const
|
|
447
|
-
|
|
524
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
525
|
+
statement.text = text;
|
|
526
|
+
const references = statement.references;
|
|
448
527
|
const index = references.findIndex((reference) => reference.hash === hash);
|
|
449
528
|
if (index < 0)
|
|
450
529
|
throw new InvalidStatementError(`Reference ${hash} does not exist`);
|
|
@@ -452,10 +531,12 @@ export class TaprootRepository {
|
|
|
452
531
|
return entity;
|
|
453
532
|
});
|
|
454
533
|
}
|
|
455
|
-
async replaceReference(id, statementId, hash, reference, edit) {
|
|
534
|
+
async replaceReference(id, statementId, hash, reference, text, edit) {
|
|
535
|
+
assertAuthoredStatementText(text, statementId);
|
|
456
536
|
return this.#mutate(id, edit, (entity) => {
|
|
457
|
-
const
|
|
458
|
-
|
|
537
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
538
|
+
statement.text = text;
|
|
539
|
+
const references = statement.references;
|
|
459
540
|
const index = references.findIndex((item) => item.hash === hash);
|
|
460
541
|
if (index < 0)
|
|
461
542
|
throw new InvalidStatementError(`Reference ${hash} does not exist`);
|
|
@@ -470,6 +551,7 @@ export class TaprootRepository {
|
|
|
470
551
|
}
|
|
471
552
|
const succeeded = new Map();
|
|
472
553
|
const failed = [];
|
|
554
|
+
let authorizationContext = options.metadata?.authorizationContext;
|
|
473
555
|
let pending = values
|
|
474
556
|
.map((entity, index) => ({ entity, index }))
|
|
475
557
|
.sort((a, b) => Number(a.entity.type !== 'property') -
|
|
@@ -479,7 +561,20 @@ export class TaprootRepository {
|
|
|
479
561
|
let progress = false;
|
|
480
562
|
for (const entry of pending) {
|
|
481
563
|
try {
|
|
482
|
-
|
|
564
|
+
const effectiveOptions = authorizationContext
|
|
565
|
+
? {
|
|
566
|
+
...options,
|
|
567
|
+
metadata: { ...options.metadata, authorizationContext },
|
|
568
|
+
}
|
|
569
|
+
: options;
|
|
570
|
+
const result = await this.#importOne(entry.entity, effectiveOptions);
|
|
571
|
+
succeeded.set(entry.index, result);
|
|
572
|
+
if (authorizationContext &&
|
|
573
|
+
result.authorizationRevision !== undefined)
|
|
574
|
+
authorizationContext = {
|
|
575
|
+
...authorizationContext,
|
|
576
|
+
authorizationRevision: result.authorizationRevision,
|
|
577
|
+
};
|
|
483
578
|
progress = true;
|
|
484
579
|
}
|
|
485
580
|
catch (error) {
|
|
@@ -526,11 +621,31 @@ export class TaprootRepository {
|
|
|
526
621
|
}
|
|
527
622
|
async #importOne(entity, options) {
|
|
528
623
|
if (options.mode === 'upsert') {
|
|
624
|
+
const authorization = options.authorizations?.[entity.id];
|
|
625
|
+
const authorizationContext = options.metadata?.authorizationContext;
|
|
626
|
+
if (authorization || authorizationContext) {
|
|
627
|
+
const currentRevision = await this.#preauthorizeCanonicalWrite(entity.id, authorization, authorizationContext);
|
|
628
|
+
if (currentRevision !== null)
|
|
629
|
+
return this.replaceEntity(entity.id, entity, {
|
|
630
|
+
expectedRevision: currentRevision,
|
|
631
|
+
statementTexts: authoredStatementTexts(entity),
|
|
632
|
+
...options.metadata,
|
|
633
|
+
...(authorization ? { authorization } : {}),
|
|
634
|
+
});
|
|
635
|
+
return this.importEntity(entity, {
|
|
636
|
+
...options.metadata,
|
|
637
|
+
...(authorization ? { authorization } : {}),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
529
640
|
try {
|
|
530
641
|
const current = await this.getEntity(entity.id);
|
|
531
642
|
return await this.replaceEntity(entity.id, entity, {
|
|
532
643
|
expectedRevision: current.entity.lastrevid,
|
|
644
|
+
statementTexts: authoredStatementTexts(entity),
|
|
533
645
|
...options.metadata,
|
|
646
|
+
...(options.authorizations?.[entity.id]
|
|
647
|
+
? { authorization: options.authorizations[entity.id] }
|
|
648
|
+
: {}),
|
|
534
649
|
});
|
|
535
650
|
}
|
|
536
651
|
catch (error) {
|
|
@@ -538,7 +653,12 @@ export class TaprootRepository {
|
|
|
538
653
|
throw error;
|
|
539
654
|
}
|
|
540
655
|
}
|
|
541
|
-
return this.importEntity(entity,
|
|
656
|
+
return this.importEntity(entity, {
|
|
657
|
+
...options.metadata,
|
|
658
|
+
...(options.authorizations?.[entity.id]
|
|
659
|
+
? { authorization: options.authorizations[entity.id] }
|
|
660
|
+
: {}),
|
|
661
|
+
});
|
|
542
662
|
}
|
|
543
663
|
async applyCommands(id, commands, edit) {
|
|
544
664
|
if (!commands.length)
|
|
@@ -729,12 +849,48 @@ export class TaprootRepository {
|
|
|
729
849
|
deletedAt: stored.deletedAt,
|
|
730
850
|
redirectTo: stored.redirectTo,
|
|
731
851
|
});
|
|
852
|
+
const authorization = await this.#db
|
|
853
|
+
.prepare(`SELECT s.installation_id, s.authorization_revision, s.search_generation,
|
|
854
|
+
s.last_advance_id
|
|
855
|
+
FROM taproot_installation_authorization s
|
|
856
|
+
JOIN taproot_entity_authorization p
|
|
857
|
+
ON p.installation_id = s.installation_id
|
|
858
|
+
WHERE s.singleton = 1 AND p.entity_id = ?
|
|
859
|
+
AND p.source_revision = ?`)
|
|
860
|
+
.bind(id, stored.entity.lastrevid)
|
|
861
|
+
.all();
|
|
862
|
+
const authorizationState = authorization.results[0];
|
|
863
|
+
if (!authorizationState)
|
|
864
|
+
throw new RevisionConflictError('Projection repair requires current canonical authorization policy');
|
|
865
|
+
const nextSearchGeneration = Number(authorizationState.search_generation) + 1;
|
|
732
866
|
const statements = [
|
|
733
867
|
this.#db
|
|
734
868
|
.prepare(`DELETE FROM taproot_terms WHERE entity_id = ?`)
|
|
735
869
|
.bind(id),
|
|
736
870
|
this.#termsInsert(stored.entity),
|
|
737
871
|
this.#auditInsert(stored.entity, metadata, context),
|
|
872
|
+
this.#db
|
|
873
|
+
.prepare(`UPDATE taproot_installation_authorization
|
|
874
|
+
SET search_generation = ?, last_advance_id = ?, updated_at = ?
|
|
875
|
+
WHERE singleton = 1 AND installation_id = ?
|
|
876
|
+
AND authorization_revision = ? AND search_generation = ?
|
|
877
|
+
AND last_advance_id = ?`)
|
|
878
|
+
.bind(nextSearchGeneration, context.eventId, context.createdAt, authorizationState.installation_id, authorizationState.authorization_revision, authorizationState.search_generation, authorizationState.last_advance_id),
|
|
879
|
+
this.#assertion(`EXISTS (
|
|
880
|
+
SELECT 1 FROM taproot_installation_authorization s
|
|
881
|
+
JOIN taproot_entity_authorization p
|
|
882
|
+
ON p.installation_id = s.installation_id
|
|
883
|
+
WHERE s.singleton = 1 AND p.entity_id = ?
|
|
884
|
+
AND p.source_revision = ? AND s.authorization_revision = ?
|
|
885
|
+
AND s.search_generation = ?
|
|
886
|
+
AND s.last_advance_id = ?
|
|
887
|
+
)`, id, stored.entity.lastrevid, authorizationState.authorization_revision, nextSearchGeneration, context.eventId),
|
|
888
|
+
this.#db
|
|
889
|
+
.prepare(`INSERT INTO taproot_authorization_projection_outbox(
|
|
890
|
+
event_id, entity_id, source_revision, authorization_revision,
|
|
891
|
+
search_generation, operation, created_at
|
|
892
|
+
) VALUES (?, ?, ?, ?, ?, 'repair', ?)`)
|
|
893
|
+
.bind(context.eventId, id, stored.entity.lastrevid, authorizationState.authorization_revision, nextSearchGeneration, context.createdAt),
|
|
738
894
|
...patch.statements,
|
|
739
895
|
...this.#ownershipPatch(id, actual, expected).statements,
|
|
740
896
|
];
|
|
@@ -746,6 +902,8 @@ export class TaprootRepository {
|
|
|
746
902
|
this.#emitObservation('repair', started, 'error', id, stored.entity.lastrevid, cause);
|
|
747
903
|
if (isEventIdUniqueError(cause))
|
|
748
904
|
throw new RevisionConflictError('Generated audit event id already exists', { cause });
|
|
905
|
+
if (isAssertionError(cause))
|
|
906
|
+
throw new RevisionConflictError('Canonical policy changed during projection repair', { cause });
|
|
749
907
|
throw cause;
|
|
750
908
|
}
|
|
751
909
|
const after = await this.inspectEntityIntegrity(id);
|
|
@@ -760,6 +918,8 @@ export class TaprootRepository {
|
|
|
760
918
|
async #create(entity, metadata, allocated, eventType) {
|
|
761
919
|
const started = performance.now();
|
|
762
920
|
validateEntity(entity);
|
|
921
|
+
if (metadata.authorization || metadata.authorizationContext)
|
|
922
|
+
await this.#preauthorizeCanonicalWrite(entity.id, metadata.authorization, metadata.authorizationContext);
|
|
763
923
|
if (await this.#loadRow(entity.id))
|
|
764
924
|
throw new EntityAlreadyExistsError(`Entity ${entity.id} already exists`);
|
|
765
925
|
await this.#validatePropertyDatatypes(entity);
|
|
@@ -767,6 +927,9 @@ export class TaprootRepository {
|
|
|
767
927
|
const json = exportEntityJson(entity, this.#options.maxEntityBytes);
|
|
768
928
|
const lifecycle = { deletedAt: null, redirectTo: null };
|
|
769
929
|
const context = await this.#writeContext(entity, json, metadata, null, eventType, lifecycle);
|
|
930
|
+
const authorization = metadata.authorization
|
|
931
|
+
? await this.#prepareAuthorizationWrite(entity, metadata.authorization, metadata.authorizationContext, undefined)
|
|
932
|
+
: undefined;
|
|
770
933
|
const newQuads = this.#lifecycleQuads(entity, lifecycle);
|
|
771
934
|
const marker = revisionQuad(entity, this.#options);
|
|
772
935
|
const patch = this.#preparePatch({ forbid: [marker], insert: newQuads });
|
|
@@ -785,7 +948,9 @@ export class TaprootRepository {
|
|
|
785
948
|
}
|
|
786
949
|
statements.push(this.#db
|
|
787
950
|
.prepare(`INSERT INTO taproot_entities(entity_id, entity_type, datatype, revision, entity_json, modified_at) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
788
|
-
.bind(entity.id, entity.type, entity.type === 'property' ? entity.datatype : null, entity.lastrevid, json, entity.modified), this.#revisionInsert(entity, json, metadata, context), this.#auditInsert(entity, metadata, context), this.#termsInsert(entity));
|
|
951
|
+
.bind(entity.id, entity.type, entity.type === 'property' ? entity.datatype : null, entity.lastrevid, json, entity.modified), this.#revisionInsert(entity, json, metadata, context), this.#auditInsert(entity, metadata, context, authorization), this.#termsInsert(entity));
|
|
952
|
+
if (authorization)
|
|
953
|
+
statements.push(...this.#authorizationWriteStatements(entity, lifecycle, context, authorization, undefined));
|
|
789
954
|
const patchOffset = statements.length;
|
|
790
955
|
statements.push(...patch.statements);
|
|
791
956
|
const ownership = this.#ownershipPatch(entity.id, [], newQuads);
|
|
@@ -804,6 +969,12 @@ export class TaprootRepository {
|
|
|
804
969
|
},
|
|
805
970
|
eventId: context.eventId,
|
|
806
971
|
contentHash: context.contentHash,
|
|
972
|
+
...(authorization
|
|
973
|
+
? {
|
|
974
|
+
authorizationRevision: authorization.authorizationRevision,
|
|
975
|
+
searchGeneration: authorization.searchGeneration,
|
|
976
|
+
}
|
|
977
|
+
: {}),
|
|
807
978
|
};
|
|
808
979
|
this.#emitObservation(eventType, started, 'success', entity.id, entity.lastrevid);
|
|
809
980
|
return result;
|
|
@@ -820,13 +991,17 @@ export class TaprootRepository {
|
|
|
820
991
|
}
|
|
821
992
|
if (isEventIdUniqueError(mapped))
|
|
822
993
|
throw new RevisionConflictError('Generated audit event id already exists', { cause: mapped });
|
|
823
|
-
if (
|
|
824
|
-
throw new RevisionConflictError(
|
|
994
|
+
if (isAssertionError(mapped))
|
|
995
|
+
throw new RevisionConflictError(allocated
|
|
996
|
+
? `ID allocation for ${entity.id} was stale`
|
|
997
|
+
: `Authorization state changed while creating ${entity.id}`, { cause: mapped });
|
|
825
998
|
throw mapped;
|
|
826
999
|
}
|
|
827
1000
|
}
|
|
828
1001
|
async #mutate(id, edit, transform, lifecycleOverride, eventType = 'update') {
|
|
829
1002
|
const started = performance.now();
|
|
1003
|
+
if (edit.authorization || edit.authorizationContext)
|
|
1004
|
+
await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
|
|
830
1005
|
const row = await this.#loadRow(id);
|
|
831
1006
|
if (!row)
|
|
832
1007
|
throw new EntityNotFoundError(`Entity ${id} was not found`);
|
|
@@ -858,6 +1033,9 @@ export class TaprootRepository {
|
|
|
858
1033
|
(await revisionContentHash(row.entity_json, oldLifecycle));
|
|
859
1034
|
const newLifecycle = lifecycleOverride ?? oldLifecycle;
|
|
860
1035
|
const context = await this.#writeContext(next, json, edit, parentHash, eventType, newLifecycle);
|
|
1036
|
+
const authorization = edit.authorization
|
|
1037
|
+
? await this.#prepareAuthorizationWrite(next, edit.authorization, edit.authorizationContext, previous)
|
|
1038
|
+
: undefined;
|
|
861
1039
|
const oldQuads = this.#lifecycleQuads(stored.entity, oldLifecycle);
|
|
862
1040
|
const newQuads = this.#lifecycleQuads(next, newLifecycle);
|
|
863
1041
|
const patch = this.#preparePatch({
|
|
@@ -871,12 +1049,14 @@ export class TaprootRepository {
|
|
|
871
1049
|
.bind(next.type === 'property' ? next.datatype : null, next.lastrevid, json, next.modified, newLifecycle.deletedAt, newLifecycle.redirectTo, id, previous),
|
|
872
1050
|
this.#assertion(`EXISTS (SELECT 1 FROM taproot_entities WHERE entity_id = ? AND revision = ?)`, id, next.lastrevid),
|
|
873
1051
|
this.#revisionInsert(next, json, edit, context),
|
|
874
|
-
this.#auditInsert(next, edit, context),
|
|
1052
|
+
this.#auditInsert(next, edit, context, authorization),
|
|
875
1053
|
this.#db
|
|
876
1054
|
.prepare('DELETE FROM taproot_terms WHERE entity_id = ?')
|
|
877
1055
|
.bind(id),
|
|
878
1056
|
this.#termsInsert(next),
|
|
879
1057
|
];
|
|
1058
|
+
if (authorization)
|
|
1059
|
+
statements.push(...this.#authorizationWriteStatements(next, newLifecycle, context, authorization, previous));
|
|
880
1060
|
const patchOffset = statements.length;
|
|
881
1061
|
statements.push(...patch.statements);
|
|
882
1062
|
const ownership = this.#ownershipPatch(id, oldQuads, newQuads);
|
|
@@ -895,6 +1075,12 @@ export class TaprootRepository {
|
|
|
895
1075
|
},
|
|
896
1076
|
eventId: context.eventId,
|
|
897
1077
|
contentHash: context.contentHash,
|
|
1078
|
+
...(authorization
|
|
1079
|
+
? {
|
|
1080
|
+
authorizationRevision: authorization.authorizationRevision,
|
|
1081
|
+
searchGeneration: authorization.searchGeneration,
|
|
1082
|
+
}
|
|
1083
|
+
: {}),
|
|
898
1084
|
};
|
|
899
1085
|
this.#emitObservation(eventType, started, 'success', id, next.lastrevid);
|
|
900
1086
|
return result;
|
|
@@ -915,6 +1101,295 @@ export class TaprootRepository {
|
|
|
915
1101
|
throw mapped;
|
|
916
1102
|
}
|
|
917
1103
|
}
|
|
1104
|
+
async #prepareAuthorizationWrite(entity, rawPolicy, rawContext, previousRevision) {
|
|
1105
|
+
const policy = normalizeCanonicalAuthorizationPolicy(rawPolicy);
|
|
1106
|
+
if (!rawContext)
|
|
1107
|
+
throw new InvalidEntityError('Guarded canonical writes require AuthorizationContext');
|
|
1108
|
+
const caller = normalizeAuthorizationContext(rawContext);
|
|
1109
|
+
if (!caller.capabilities.includes(KNOWLEDGE_WRITE_CAPABILITY))
|
|
1110
|
+
throw new InvalidEntityError(`Canonical writes require ${KNOWLEDGE_WRITE_CAPABILITY}`);
|
|
1111
|
+
const expectedStatementIds = statementIds(entity);
|
|
1112
|
+
const providedStatementIds = Object.keys(policy.statementRestrictions).sort(compareCodeUnits);
|
|
1113
|
+
if (JSON.stringify(expectedStatementIds) !==
|
|
1114
|
+
JSON.stringify(providedStatementIds))
|
|
1115
|
+
throw new InvalidEntityError('statementRestrictions must explicitly and exactly cover the post-mutation statements');
|
|
1116
|
+
const state = await this.#db
|
|
1117
|
+
.prepare(`SELECT installation_id, authorization_revision, search_generation,
|
|
1118
|
+
last_advance_id
|
|
1119
|
+
FROM taproot_installation_authorization WHERE singleton = 1`)
|
|
1120
|
+
.all();
|
|
1121
|
+
const installation = state.results[0];
|
|
1122
|
+
if (!installation ||
|
|
1123
|
+
installation.installation_id !== policy.installationId ||
|
|
1124
|
+
Number(installation.authorization_revision) !==
|
|
1125
|
+
policy.expectedAuthorizationRevision ||
|
|
1126
|
+
caller.installationId !== policy.installationId ||
|
|
1127
|
+
caller.authorizationRevision !== policy.expectedAuthorizationRevision)
|
|
1128
|
+
throw new RevisionConflictError('Installation authorization revision is stale or mismatched');
|
|
1129
|
+
if (policy.workspaceId !== null &&
|
|
1130
|
+
!caller.workspaceIds.includes(policy.workspaceId))
|
|
1131
|
+
throw new InvalidEntityError('Canonical policy workspace must belong to the caller context');
|
|
1132
|
+
if (!isVisibleTo(policy.visibility, caller))
|
|
1133
|
+
throw new InvalidEntityError('Canonical policy must remain visible to its creating or updating caller');
|
|
1134
|
+
if (previousRevision === undefined &&
|
|
1135
|
+
policy.ownerPrincipalId !== caller.principalId &&
|
|
1136
|
+
!caller.capabilities.includes(KNOWLEDGE_POLICY_CAPABILITY))
|
|
1137
|
+
throw new InvalidEntityError(`Creating policy for another owner requires ${KNOWLEDGE_POLICY_CAPABILITY}`);
|
|
1138
|
+
const statementRows = expectedStatementIds.map((statementId) => {
|
|
1139
|
+
const restrictions = policy.statementRestrictions[statementId];
|
|
1140
|
+
return {
|
|
1141
|
+
statementId,
|
|
1142
|
+
restrictionsJson: JSON.stringify(restrictions),
|
|
1143
|
+
effectiveVisibilityJson: serializeVisibilityScope(intersectVisibilityScopes(policy.visibility, ...restrictions)),
|
|
1144
|
+
};
|
|
1145
|
+
});
|
|
1146
|
+
const effectiveVisibilityJson = serializeVisibilityScope(intersectVisibilityScopes(policy.visibility, ...statementRows.map(({ effectiveVisibilityJson: serialized }) => JSON.parse(serialized))));
|
|
1147
|
+
let action = 'create';
|
|
1148
|
+
if (previousRevision !== undefined) {
|
|
1149
|
+
const current = await this.#db
|
|
1150
|
+
.prepare(`SELECT installation_id, source_revision, visibility_json,
|
|
1151
|
+
effective_visibility_json, workspace_id, owner_principal_id
|
|
1152
|
+
FROM taproot_entity_authorization WHERE entity_id = ?`)
|
|
1153
|
+
.bind(entity.id)
|
|
1154
|
+
.all();
|
|
1155
|
+
const previousPolicy = current.results[0];
|
|
1156
|
+
if (!previousPolicy ||
|
|
1157
|
+
previousPolicy.installation_id !== policy.installationId ||
|
|
1158
|
+
Number(previousPolicy.source_revision) !== previousRevision)
|
|
1159
|
+
throw new RevisionConflictError('Canonical entity policy is missing, quarantined, or stale');
|
|
1160
|
+
assertScopeDoesNotWiden(policy.visibility, JSON.parse(previousPolicy.visibility_json), 'entity visibility');
|
|
1161
|
+
if (!isVisibleTo(JSON.parse(previousPolicy.effective_visibility_json), caller))
|
|
1162
|
+
throw new InvalidEntityError('Caller is not authorized for the current canonical policy');
|
|
1163
|
+
const previousStatements = await this.#db
|
|
1164
|
+
.prepare(`SELECT statement_id, restrictions_json, effective_visibility_json
|
|
1165
|
+
FROM taproot_statement_authorization
|
|
1166
|
+
WHERE entity_id = ? AND source_revision = ?`)
|
|
1167
|
+
.bind(entity.id, previousRevision)
|
|
1168
|
+
.all();
|
|
1169
|
+
const nextById = new Map(statementRows.map((row) => [row.statementId, row]));
|
|
1170
|
+
const previousRestrictions = Object.fromEntries(previousStatements.results
|
|
1171
|
+
.map((row) => {
|
|
1172
|
+
const parsed = JSON.parse(row.restrictions_json);
|
|
1173
|
+
if (!Array.isArray(parsed))
|
|
1174
|
+
throw new InvalidEntityError(`Stored statement ${row.statement_id} restrictions are invalid`);
|
|
1175
|
+
return [
|
|
1176
|
+
row.statement_id,
|
|
1177
|
+
parsed.map((scope) => normalizeVisibilityScope(scope)),
|
|
1178
|
+
];
|
|
1179
|
+
})
|
|
1180
|
+
.sort(([left], [right]) => compareCodeUnits(String(left), String(right))));
|
|
1181
|
+
for (const previous of previousStatements.results) {
|
|
1182
|
+
const next = nextById.get(previous.statement_id);
|
|
1183
|
+
if (!next)
|
|
1184
|
+
continue;
|
|
1185
|
+
assertScopeDoesNotWiden(JSON.parse(next.effectiveVisibilityJson), JSON.parse(previous.effective_visibility_json), `statement ${previous.statement_id} visibility`);
|
|
1186
|
+
}
|
|
1187
|
+
const changed = previousPolicy.workspace_id !== policy.workspaceId ||
|
|
1188
|
+
previousPolicy.owner_principal_id !== policy.ownerPrincipalId ||
|
|
1189
|
+
previousPolicy.visibility_json !==
|
|
1190
|
+
serializeVisibilityScope(policy.visibility) ||
|
|
1191
|
+
JSON.stringify(previousRestrictions) !==
|
|
1192
|
+
JSON.stringify(policy.statementRestrictions);
|
|
1193
|
+
action = changed ? 'policy-change' : 'unchanged';
|
|
1194
|
+
if (changed && !caller.capabilities.includes(KNOWLEDGE_POLICY_CAPABILITY))
|
|
1195
|
+
throw new InvalidEntityError(`Canonical policy changes require ${KNOWLEDGE_POLICY_CAPABILITY}`);
|
|
1196
|
+
}
|
|
1197
|
+
const authorizationRevision = policy.expectedAuthorizationRevision + 1;
|
|
1198
|
+
const searchGeneration = Number(installation.search_generation) + 1;
|
|
1199
|
+
if (!Number.isSafeInteger(authorizationRevision) ||
|
|
1200
|
+
!Number.isSafeInteger(searchGeneration))
|
|
1201
|
+
throw new RevisionConflictError('Installation authorization counters are exhausted');
|
|
1202
|
+
return {
|
|
1203
|
+
policy,
|
|
1204
|
+
previousAdvanceId: installation.last_advance_id,
|
|
1205
|
+
authorizationRevision,
|
|
1206
|
+
searchGeneration,
|
|
1207
|
+
effectiveVisibilityJson,
|
|
1208
|
+
action,
|
|
1209
|
+
statementRows,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
async #preauthorizeCanonicalWrite(entityId, rawPolicy, rawContext, expectedRevision) {
|
|
1213
|
+
if (!rawPolicy || !rawContext)
|
|
1214
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1215
|
+
let policy;
|
|
1216
|
+
let caller;
|
|
1217
|
+
try {
|
|
1218
|
+
policy = normalizeCanonicalAuthorizationPolicy(rawPolicy);
|
|
1219
|
+
caller = normalizeAuthorizationContext(rawContext);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1223
|
+
}
|
|
1224
|
+
if (!caller.capabilities.includes(KNOWLEDGE_WRITE_CAPABILITY))
|
|
1225
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1226
|
+
const result = await this.#db
|
|
1227
|
+
.prepare(`SELECT s.installation_id, s.authorization_revision,
|
|
1228
|
+
e.entity_id, e.revision,
|
|
1229
|
+
p.installation_id AS policy_installation_id,
|
|
1230
|
+
p.source_revision AS policy_source_revision,
|
|
1231
|
+
p.effective_visibility_json
|
|
1232
|
+
FROM taproot_installation_authorization s
|
|
1233
|
+
LEFT JOIN taproot_entities e ON e.entity_id = ?
|
|
1234
|
+
LEFT JOIN taproot_entity_authorization p
|
|
1235
|
+
ON p.entity_id = e.entity_id AND p.source_revision = e.revision
|
|
1236
|
+
WHERE s.singleton = 1`)
|
|
1237
|
+
.bind(entityId)
|
|
1238
|
+
.all();
|
|
1239
|
+
const row = result.results[0];
|
|
1240
|
+
if (!row ||
|
|
1241
|
+
row.installation_id !== policy.installationId ||
|
|
1242
|
+
row.installation_id !== caller.installationId ||
|
|
1243
|
+
Number(row.authorization_revision) !==
|
|
1244
|
+
policy.expectedAuthorizationRevision ||
|
|
1245
|
+
Number(row.authorization_revision) !== caller.authorizationRevision)
|
|
1246
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1247
|
+
if (row.entity_id === null) {
|
|
1248
|
+
if (expectedRevision !== undefined)
|
|
1249
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1250
|
+
if ((policy.workspaceId !== null &&
|
|
1251
|
+
!caller.workspaceIds.includes(policy.workspaceId)) ||
|
|
1252
|
+
!isVisibleTo(policy.visibility, caller) ||
|
|
1253
|
+
(policy.ownerPrincipalId !== caller.principalId &&
|
|
1254
|
+
!caller.capabilities.includes(KNOWLEDGE_POLICY_CAPABILITY)))
|
|
1255
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
if (row.policy_installation_id !== row.installation_id ||
|
|
1259
|
+
row.policy_source_revision === null ||
|
|
1260
|
+
row.effective_visibility_json === null ||
|
|
1261
|
+
!isVisibleTo(JSON.parse(row.effective_visibility_json), caller))
|
|
1262
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1263
|
+
if (expectedRevision !== undefined &&
|
|
1264
|
+
Number(row.policy_source_revision) !== expectedRevision)
|
|
1265
|
+
throw new RevisionConflictError('Canonical revision is stale');
|
|
1266
|
+
return Number(row.policy_source_revision);
|
|
1267
|
+
}
|
|
1268
|
+
async #authorizedLifecycle(entityId, rawContext) {
|
|
1269
|
+
const caller = normalizeAuthorizationContext(rawContext);
|
|
1270
|
+
if (!caller.capabilities.includes(KNOWLEDGE_WRITE_CAPABILITY))
|
|
1271
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1272
|
+
const result = await this.#db
|
|
1273
|
+
.prepare(`SELECT e.entity_type, e.deleted_at, e.redirect_to,
|
|
1274
|
+
p.effective_visibility_json
|
|
1275
|
+
FROM taproot_installation_authorization s
|
|
1276
|
+
JOIN taproot_entities e ON e.entity_id = ?
|
|
1277
|
+
JOIN taproot_entity_authorization p
|
|
1278
|
+
ON p.entity_id = e.entity_id AND p.source_revision = e.revision
|
|
1279
|
+
WHERE s.singleton = 1
|
|
1280
|
+
AND s.installation_id = ? AND s.authorization_revision = ?
|
|
1281
|
+
AND p.installation_id = s.installation_id`)
|
|
1282
|
+
.bind(entityId, caller.installationId, caller.authorizationRevision)
|
|
1283
|
+
.all();
|
|
1284
|
+
const row = result.results[0];
|
|
1285
|
+
if (!row ||
|
|
1286
|
+
!isVisibleTo(JSON.parse(row.effective_visibility_json), caller))
|
|
1287
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
1288
|
+
return {
|
|
1289
|
+
type: row.entity_type,
|
|
1290
|
+
deletedAt: row.deleted_at,
|
|
1291
|
+
redirectTo: row.redirect_to,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
async #validateRedirectTarget(sourceId, targetId, sourceType, authorizationContext) {
|
|
1295
|
+
const seen = new Set([sourceId]);
|
|
1296
|
+
let cursor = targetId;
|
|
1297
|
+
for (let depth = 0; cursor && depth < 100; depth += 1) {
|
|
1298
|
+
if (seen.has(cursor))
|
|
1299
|
+
throw new InvalidEntityError('Redirect would create a cycle');
|
|
1300
|
+
seen.add(cursor);
|
|
1301
|
+
const target = authorizationContext
|
|
1302
|
+
? await this.#authorizedLifecycle(cursor, authorizationContext)
|
|
1303
|
+
: await this.getEntity(cursor).then(({ entity, deletedAt, redirectTo }) => ({
|
|
1304
|
+
type: entity.type,
|
|
1305
|
+
deletedAt,
|
|
1306
|
+
redirectTo,
|
|
1307
|
+
}));
|
|
1308
|
+
if (target.type !== sourceType)
|
|
1309
|
+
throw new InvalidEntityError('Redirect source and target must have the same entity type');
|
|
1310
|
+
if (target.deletedAt)
|
|
1311
|
+
throw new InvalidEntityError('An entity cannot redirect to a deleted target');
|
|
1312
|
+
cursor = target.redirectTo;
|
|
1313
|
+
}
|
|
1314
|
+
if (cursor)
|
|
1315
|
+
throw new InvalidEntityError('Redirect chain exceeds 100 entities');
|
|
1316
|
+
}
|
|
1317
|
+
#authorizationWriteStatements(entity, lifecycle, context, prepared, previousRevision) {
|
|
1318
|
+
const { policy, authorizationRevision, searchGeneration, effectiveVisibilityJson, statementRows, previousAdvanceId, } = prepared;
|
|
1319
|
+
const visibilityJson = serializeVisibilityScope(policy.visibility);
|
|
1320
|
+
const rowsJson = JSON.stringify(statementRows);
|
|
1321
|
+
const statements = [
|
|
1322
|
+
this.#db
|
|
1323
|
+
.prepare(`UPDATE taproot_installation_authorization
|
|
1324
|
+
SET authorization_revision = ?, search_generation = ?, updated_at = ?
|
|
1325
|
+
, last_advance_id = ?
|
|
1326
|
+
WHERE singleton = 1 AND installation_id = ?
|
|
1327
|
+
AND authorization_revision = ? AND search_generation = ?
|
|
1328
|
+
AND last_advance_id = ?`)
|
|
1329
|
+
.bind(authorizationRevision, searchGeneration, context.createdAt, context.eventId, policy.installationId, policy.expectedAuthorizationRevision, searchGeneration - 1, previousAdvanceId),
|
|
1330
|
+
this.#assertion(`EXISTS (
|
|
1331
|
+
SELECT 1 FROM taproot_installation_authorization
|
|
1332
|
+
WHERE singleton = 1 AND installation_id = ?
|
|
1333
|
+
AND authorization_revision = ? AND search_generation = ?
|
|
1334
|
+
AND last_advance_id = ?
|
|
1335
|
+
)`, policy.installationId, authorizationRevision, searchGeneration, context.eventId),
|
|
1336
|
+
];
|
|
1337
|
+
if (previousRevision === undefined) {
|
|
1338
|
+
statements.push(this.#db
|
|
1339
|
+
.prepare(`INSERT INTO taproot_entity_authorization(
|
|
1340
|
+
entity_id, installation_id, workspace_id, owner_principal_id,
|
|
1341
|
+
visibility_json, effective_visibility_json, source_revision, authorization_revision,
|
|
1342
|
+
deleted_at, event_id, updated_at
|
|
1343
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1344
|
+
.bind(entity.id, policy.installationId, policy.workspaceId, policy.ownerPrincipalId, visibilityJson, effectiveVisibilityJson, entity.lastrevid, authorizationRevision, lifecycle.deletedAt, context.eventId, context.createdAt));
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
statements.push(this.#db
|
|
1348
|
+
.prepare(`UPDATE taproot_entity_authorization
|
|
1349
|
+
SET workspace_id = ?, owner_principal_id = ?, visibility_json = ?,
|
|
1350
|
+
effective_visibility_json = ?, source_revision = ?, authorization_revision = ?, deleted_at = ?,
|
|
1351
|
+
event_id = ?, updated_at = ?
|
|
1352
|
+
WHERE entity_id = ? AND installation_id = ? AND source_revision = ?`)
|
|
1353
|
+
.bind(policy.workspaceId, policy.ownerPrincipalId, visibilityJson, effectiveVisibilityJson, entity.lastrevid, authorizationRevision, lifecycle.deletedAt, context.eventId, context.createdAt, entity.id, policy.installationId, previousRevision), this.#assertion(`EXISTS (
|
|
1354
|
+
SELECT 1 FROM taproot_entity_authorization
|
|
1355
|
+
WHERE entity_id = ? AND installation_id = ?
|
|
1356
|
+
AND source_revision = ? AND authorization_revision = ?
|
|
1357
|
+
)`, entity.id, policy.installationId, entity.lastrevid, authorizationRevision));
|
|
1358
|
+
}
|
|
1359
|
+
statements.push(this.#db
|
|
1360
|
+
.prepare(`INSERT INTO taproot_entity_authorization_revisions(
|
|
1361
|
+
entity_id, source_revision, installation_id, workspace_id,
|
|
1362
|
+
owner_principal_id, visibility_json, effective_visibility_json, authorization_revision,
|
|
1363
|
+
deleted_at, event_id, created_at
|
|
1364
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1365
|
+
.bind(entity.id, entity.lastrevid, policy.installationId, policy.workspaceId, policy.ownerPrincipalId, visibilityJson, effectiveVisibilityJson, authorizationRevision, lifecycle.deletedAt, context.eventId, context.createdAt), this.#db
|
|
1366
|
+
.prepare(`DELETE FROM taproot_statement_authorization WHERE entity_id = ?`)
|
|
1367
|
+
.bind(entity.id), this.#db
|
|
1368
|
+
.prepare(`INSERT INTO taproot_statement_authorization(
|
|
1369
|
+
entity_id, statement_id, source_revision, restrictions_json,
|
|
1370
|
+
effective_visibility_json, authorization_revision
|
|
1371
|
+
)
|
|
1372
|
+
SELECT ?, json_extract(value, '$.statementId'), ?,
|
|
1373
|
+
json_extract(value, '$.restrictionsJson'),
|
|
1374
|
+
json_extract(value, '$.effectiveVisibilityJson'), ?
|
|
1375
|
+
FROM json_each(?)`)
|
|
1376
|
+
.bind(entity.id, entity.lastrevid, authorizationRevision, rowsJson), this.#db
|
|
1377
|
+
.prepare(`INSERT INTO taproot_statement_authorization_revisions(
|
|
1378
|
+
entity_id, source_revision, statement_id, restrictions_json,
|
|
1379
|
+
effective_visibility_json, authorization_revision
|
|
1380
|
+
)
|
|
1381
|
+
SELECT ?, ?, json_extract(value, '$.statementId'),
|
|
1382
|
+
json_extract(value, '$.restrictionsJson'),
|
|
1383
|
+
json_extract(value, '$.effectiveVisibilityJson'), ?
|
|
1384
|
+
FROM json_each(?)`)
|
|
1385
|
+
.bind(entity.id, entity.lastrevid, authorizationRevision, rowsJson), this.#db
|
|
1386
|
+
.prepare(`INSERT INTO taproot_authorization_projection_outbox(
|
|
1387
|
+
event_id, entity_id, source_revision, authorization_revision,
|
|
1388
|
+
search_generation, operation, created_at
|
|
1389
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
1390
|
+
.bind(context.eventId, entity.id, entity.lastrevid, authorizationRevision, searchGeneration, lifecycle.deletedAt ? 'delete' : 'upsert', context.createdAt));
|
|
1391
|
+
return statements;
|
|
1392
|
+
}
|
|
918
1393
|
#preparePatch(patch) {
|
|
919
1394
|
try {
|
|
920
1395
|
return prepareQuadPatch(this.#db, patch);
|
|
@@ -1045,13 +1520,24 @@ export class TaprootRepository {
|
|
|
1045
1520
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1046
1521
|
.bind(entity.id, entity.lastrevid, json, metadata.actor ?? context.attribution?.id ?? null, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), context.eventId, context.contentHash, context.parentHash, context.lifecycle.deletedAt, context.lifecycle.redirectTo, context.createdAt);
|
|
1047
1522
|
}
|
|
1048
|
-
#auditInsert(entity, metadata, context) {
|
|
1523
|
+
#auditInsert(entity, metadata, context, authorization) {
|
|
1049
1524
|
return this.#db
|
|
1050
1525
|
.prepare(`INSERT INTO taproot_audit_events(
|
|
1051
1526
|
event_id, entity_id, revision, event_type, attribution_json, edit_summary,
|
|
1052
1527
|
tags_json, request_id, content_hash, parent_hash, details_json, created_at
|
|
1053
1528
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1054
|
-
.bind(context.eventId, entity.id, entity.lastrevid, context.eventType, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), metadata.requestId ?? null, context.contentHash, context.parentHash, JSON.stringify(
|
|
1529
|
+
.bind(context.eventId, entity.id, entity.lastrevid, context.eventType, context.attribution ? JSON.stringify(context.attribution) : null, metadata.editSummary ?? null, JSON.stringify(context.tags), metadata.requestId ?? null, context.contentHash, context.parentHash, JSON.stringify({
|
|
1530
|
+
...context.lifecycle,
|
|
1531
|
+
...(authorization
|
|
1532
|
+
? {
|
|
1533
|
+
authorization: {
|
|
1534
|
+
action: authorization.action,
|
|
1535
|
+
authorizationRevision: authorization.authorizationRevision,
|
|
1536
|
+
searchGeneration: authorization.searchGeneration,
|
|
1537
|
+
},
|
|
1538
|
+
}
|
|
1539
|
+
: {}),
|
|
1540
|
+
}), context.createdAt);
|
|
1055
1541
|
}
|
|
1056
1542
|
async #writeContext(entity, json, metadata, parentHash, eventType, lifecycle) {
|
|
1057
1543
|
const attribution = normalizeAttribution(metadata);
|
|
@@ -1120,10 +1606,6 @@ export class TaprootRepository {
|
|
|
1120
1606
|
}
|
|
1121
1607
|
#namespaceStatements() {
|
|
1122
1608
|
return [
|
|
1123
|
-
this.#db
|
|
1124
|
-
.prepare(`INSERT INTO taproot_metadata(metadata_key, metadata_value)
|
|
1125
|
-
VALUES ('base_iri', ?) ON CONFLICT(metadata_key) DO NOTHING`)
|
|
1126
|
-
.bind(withoutTrailingSlashes(this.#options.baseIri)),
|
|
1127
1609
|
this.#assertion(`EXISTS (SELECT 1 FROM taproot_metadata
|
|
1128
1610
|
WHERE metadata_key = 'base_iri' AND metadata_value = ?)`, withoutTrailingSlashes(this.#options.baseIri)),
|
|
1129
1611
|
];
|
|
@@ -1239,6 +1721,26 @@ function locateStatement(entity, id) {
|
|
|
1239
1721
|
}
|
|
1240
1722
|
throw new InvalidStatementError(`Statement ${id} does not exist`);
|
|
1241
1723
|
}
|
|
1724
|
+
function resupplyStatementTexts(entity, supplied) {
|
|
1725
|
+
const expected = new Set();
|
|
1726
|
+
for (const statements of Object.values(entity.claims)) {
|
|
1727
|
+
for (const statement of statements) {
|
|
1728
|
+
expected.add(statement.id);
|
|
1729
|
+
if (!Object.hasOwn(supplied, statement.id))
|
|
1730
|
+
throw new InvalidStatementError(`Statement ${statement.id} text must be explicitly resupplied for this revision`);
|
|
1731
|
+
const text = supplied[statement.id];
|
|
1732
|
+
assertAuthoredStatementText(text, statement.id);
|
|
1733
|
+
statement.text = text;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
const unexpected = Object.keys(supplied).filter((id) => !expected.has(id));
|
|
1737
|
+
if (unexpected.length)
|
|
1738
|
+
throw new InvalidStatementError(`Statement text was supplied for unknown statement ${unexpected[0]}`);
|
|
1739
|
+
return entity;
|
|
1740
|
+
}
|
|
1741
|
+
function authoredStatementTexts(entity) {
|
|
1742
|
+
return Object.fromEntries(Object.values(entity.claims).flatMap((statements) => statements.map((statement) => [statement.id, statement.text])));
|
|
1743
|
+
}
|
|
1242
1744
|
function collectSnak(snak, used) {
|
|
1243
1745
|
const current = used.get(snak.property);
|
|
1244
1746
|
if (current && current !== snak.datatype)
|
|
@@ -1246,6 +1748,15 @@ function collectSnak(snak, used) {
|
|
|
1246
1748
|
used.set(snak.property, snak.datatype);
|
|
1247
1749
|
}
|
|
1248
1750
|
function applyEntityCommand(entity, command) {
|
|
1751
|
+
if (command.type === 'add-statement' || command.type === 'replace-statement')
|
|
1752
|
+
assertAuthoredStatementText(command.statement.text, command.statement.id);
|
|
1753
|
+
else if (command.type === 'set-statement-rank' ||
|
|
1754
|
+
command.type === 'add-qualifier' ||
|
|
1755
|
+
command.type === 'remove-qualifier' ||
|
|
1756
|
+
command.type === 'add-reference' ||
|
|
1757
|
+
command.type === 'replace-reference' ||
|
|
1758
|
+
command.type === 'remove-reference')
|
|
1759
|
+
assertAuthoredStatementText(command.text, command.statementId);
|
|
1249
1760
|
switch (command.type) {
|
|
1250
1761
|
case 'set-label':
|
|
1251
1762
|
entity.labels[command.language] = {
|
|
@@ -1314,10 +1825,13 @@ function applyEntityCommand(entity, command) {
|
|
|
1314
1825
|
case 'set-statement-rank':
|
|
1315
1826
|
locateStatement(entity, command.statementId).statement.rank =
|
|
1316
1827
|
command.rank;
|
|
1828
|
+
locateStatement(entity, command.statementId).statement.text =
|
|
1829
|
+
command.text;
|
|
1317
1830
|
break;
|
|
1318
1831
|
case 'add-qualifier': {
|
|
1319
1832
|
validateSnak(command.snak);
|
|
1320
1833
|
const statement = locateStatement(entity, command.statementId).statement;
|
|
1834
|
+
statement.text = command.text;
|
|
1321
1835
|
const property = command.snak.property;
|
|
1322
1836
|
if (!statement.qualifiers[property]) {
|
|
1323
1837
|
statement.qualifiers[property] = [];
|
|
@@ -1328,6 +1842,7 @@ function applyEntityCommand(entity, command) {
|
|
|
1328
1842
|
}
|
|
1329
1843
|
case 'remove-qualifier': {
|
|
1330
1844
|
const statement = locateStatement(entity, command.statementId).statement;
|
|
1845
|
+
statement.text = command.text;
|
|
1331
1846
|
const snaks = statement.qualifiers[command.property];
|
|
1332
1847
|
if (!snaks?.[command.ordinal])
|
|
1333
1848
|
throw new InvalidStatementError('Qualifier does not exist');
|
|
@@ -1338,12 +1853,16 @@ function applyEntityCommand(entity, command) {
|
|
|
1338
1853
|
}
|
|
1339
1854
|
break;
|
|
1340
1855
|
}
|
|
1341
|
-
case 'add-reference':
|
|
1342
|
-
locateStatement(entity, command.statementId).statement
|
|
1856
|
+
case 'add-reference': {
|
|
1857
|
+
const statement = locateStatement(entity, command.statementId).statement;
|
|
1858
|
+
statement.text = command.text;
|
|
1859
|
+
statement.references.push(structuredClone(command.reference));
|
|
1343
1860
|
break;
|
|
1861
|
+
}
|
|
1344
1862
|
case 'replace-reference': {
|
|
1345
|
-
const
|
|
1346
|
-
|
|
1863
|
+
const statement = locateStatement(entity, command.statementId).statement;
|
|
1864
|
+
statement.text = command.text;
|
|
1865
|
+
const references = statement.references;
|
|
1347
1866
|
const index = references.findIndex(({ hash }) => hash === command.hash);
|
|
1348
1867
|
if (index < 0)
|
|
1349
1868
|
throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
|
|
@@ -1351,8 +1870,9 @@ function applyEntityCommand(entity, command) {
|
|
|
1351
1870
|
break;
|
|
1352
1871
|
}
|
|
1353
1872
|
case 'remove-reference': {
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1873
|
+
const statement = locateStatement(entity, command.statementId).statement;
|
|
1874
|
+
statement.text = command.text;
|
|
1875
|
+
const references = statement.references;
|
|
1356
1876
|
const index = references.findIndex(({ hash }) => hash === command.hash);
|
|
1357
1877
|
if (index < 0)
|
|
1358
1878
|
throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
|
|
@@ -1377,10 +1897,10 @@ function isUniqueEntityError(cause) {
|
|
|
1377
1897
|
return /UNIQUE constraint failed: taproot_entities\.entity_id/iu.test(cause.message);
|
|
1378
1898
|
}
|
|
1379
1899
|
function isRevisionUniqueError(cause) {
|
|
1380
|
-
return /UNIQUE constraint failed: taproot_entity_revisions\.entity_id, taproot_entity_revisions\.revision/iu.test(cause.message);
|
|
1900
|
+
return (/UNIQUE constraint failed: taproot_entity_revisions\.entity_id, taproot_entity_revisions\.revision/iu.test(cause.message) || /taproot revisions cannot be replaced/iu.test(cause.message));
|
|
1381
1901
|
}
|
|
1382
1902
|
function isEventIdUniqueError(cause) {
|
|
1383
|
-
return /UNIQUE constraint failed: taproot_audit_events\.event_id/iu.test(cause.message);
|
|
1903
|
+
return (/UNIQUE constraint failed: taproot_audit_events\.event_id/iu.test(cause.message) || /taproot audit events cannot be replaced/iu.test(cause.message));
|
|
1384
1904
|
}
|
|
1385
1905
|
function normalizeAttribution(metadata) {
|
|
1386
1906
|
if (metadata.actor !== undefined && typeof metadata.actor !== 'string')
|
|
@@ -1493,6 +2013,19 @@ function sameQuads(left, right) {
|
|
|
1493
2013
|
const b = [...new Set(right.map(quadKeyValue))].sort();
|
|
1494
2014
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1495
2015
|
}
|
|
2016
|
+
function statementIds(entity) {
|
|
2017
|
+
return Object.values(entity.claims)
|
|
2018
|
+
.flatMap((statements) => statements.map(({ id }) => id))
|
|
2019
|
+
.sort(compareCodeUnits);
|
|
2020
|
+
}
|
|
2021
|
+
function assertScopeDoesNotWiden(next, previous, field) {
|
|
2022
|
+
const intersection = serializeVisibilityScope(intersectVisibilityScopes(next, previous));
|
|
2023
|
+
if (intersection !== serializeVisibilityScope(next))
|
|
2024
|
+
throw new InvalidEntityError(`${field} cannot widen canonical policy`);
|
|
2025
|
+
}
|
|
2026
|
+
function compareCodeUnits(left, right) {
|
|
2027
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2028
|
+
}
|
|
1496
2029
|
function toError(error) {
|
|
1497
2030
|
return error instanceof Error ? error : new Error(String(error));
|
|
1498
2031
|
}
|