@gnolith/taproot 0.2.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 +53 -0
- package/COMPATIBILITY.md +5 -3
- package/README.md +106 -33
- package/SECURITY.md +6 -3
- 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 +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +125 -49
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +351 -44
- package/dist/index.js.map +1 -1
- package/dist/migrations.d.ts +9 -3
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +77 -5
- package/dist/migrations.js.map +1 -1
- package/dist/repository.d.ts +20 -9
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +593 -48
- package/dist/repository.js.map +1 -1
- package/dist/schema.d.ts +14 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +461 -18
- 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 +56 -18
- package/docs/architecture.md +19 -7
- package/docs/authorization.md +82 -0
- package/docs/completion-audit.md +15 -15
- package/docs/operations.md +28 -5
- package/docs/product-scope.md +5 -3
- package/docs/testing.md +18 -1
- package/docs/threat-model.md +30 -3
- package/examples/d1-diamond-interop/README.md +2 -1
- package/examples/d1-diamond-interop/demo.ts +150 -21
- package/migrations/0003_canonical_statement_text.sql +31 -0
- package/migrations/0004_canonical_authorization_policy.sql +213 -0
- package/package.json +4 -1
package/dist/repository.js
CHANGED
|
@@ -1,11 +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
7
|
import { canonicalizeTaprootBaseIri } from './migrations.js';
|
|
8
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. */
|
|
9
11
|
export class TaprootRepository {
|
|
10
12
|
#db;
|
|
11
13
|
#options;
|
|
@@ -29,6 +31,79 @@ export class TaprootRepository {
|
|
|
29
31
|
...(options.factory ? { factory: options.factory } : {}),
|
|
30
32
|
};
|
|
31
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
|
+
}
|
|
32
107
|
async getEntity(id) {
|
|
33
108
|
const row = await this.#loadRow(id);
|
|
34
109
|
if (!row)
|
|
@@ -265,11 +340,15 @@ export class TaprootRepository {
|
|
|
265
340
|
async replaceEntity(id, replacement, edit) {
|
|
266
341
|
if (replacement.id !== id)
|
|
267
342
|
throw new InvalidEntityError('Replacement entity id cannot change');
|
|
268
|
-
return this.#mutate(id, edit, () => cloneEntity(replacement), undefined, 'update');
|
|
343
|
+
return this.#mutate(id, edit, () => resupplyStatementTexts(cloneEntity(replacement), edit.statementTexts), undefined, 'update');
|
|
269
344
|
}
|
|
270
345
|
async revertEntity(id, targetRevision, edit) {
|
|
346
|
+
if (edit.authorization || edit.authorizationContext)
|
|
347
|
+
await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
|
|
271
348
|
const target = await this.getEntityRevision(id, targetRevision);
|
|
272
|
-
|
|
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');
|
|
273
352
|
}
|
|
274
353
|
async softDeleteEntity(id, edit) {
|
|
275
354
|
return this.#mutate(id, edit, (entity) => entity, {
|
|
@@ -286,22 +365,16 @@ export class TaprootRepository {
|
|
|
286
365
|
async redirectEntity(id, target, edit) {
|
|
287
366
|
if (id === target)
|
|
288
367
|
throw new InvalidEntityError('An entity cannot redirect to itself');
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
throw new InvalidEntityError('Redirect would create a cycle');
|
|
300
|
-
seen.add(cursor);
|
|
301
|
-
cursor = (await this.getEntity(cursor)).redirectTo;
|
|
302
|
-
}
|
|
303
|
-
if (cursor)
|
|
304
|
-
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);
|
|
305
378
|
return this.#mutate(id, edit, (entity) => entity, {
|
|
306
379
|
deletedAt: null,
|
|
307
380
|
redirectTo: target,
|
|
@@ -366,6 +439,7 @@ export class TaprootRepository {
|
|
|
366
439
|
});
|
|
367
440
|
}
|
|
368
441
|
async addStatement(id, statement, edit) {
|
|
442
|
+
assertAuthoredStatementText(statement.text, statement.id);
|
|
369
443
|
return this.#mutate(id, edit, (entity) => {
|
|
370
444
|
const property = statement.mainsnak.property;
|
|
371
445
|
entity.claims[property] ??= [];
|
|
@@ -374,6 +448,7 @@ export class TaprootRepository {
|
|
|
374
448
|
});
|
|
375
449
|
}
|
|
376
450
|
async replaceStatement(id, statementId, statement, edit) {
|
|
451
|
+
assertAuthoredStatementText(statement.text, statement.id);
|
|
377
452
|
return this.#mutate(id, edit, (entity) => {
|
|
378
453
|
const located = locateStatement(entity, statementId);
|
|
379
454
|
located.statements.splice(located.index, 1);
|
|
@@ -394,16 +469,21 @@ export class TaprootRepository {
|
|
|
394
469
|
return entity;
|
|
395
470
|
});
|
|
396
471
|
}
|
|
397
|
-
async setStatementRank(id, statementId, rank, edit) {
|
|
472
|
+
async setStatementRank(id, statementId, rank, text, edit) {
|
|
473
|
+
assertAuthoredStatementText(text, statementId);
|
|
398
474
|
return this.#mutate(id, edit, (entity) => {
|
|
399
|
-
locateStatement(entity, statementId).statement
|
|
475
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
476
|
+
statement.rank = rank;
|
|
477
|
+
statement.text = text;
|
|
400
478
|
return entity;
|
|
401
479
|
});
|
|
402
480
|
}
|
|
403
|
-
async addQualifier(id, statementId, snak, edit) {
|
|
481
|
+
async addQualifier(id, statementId, snak, text, edit) {
|
|
482
|
+
assertAuthoredStatementText(text, statementId);
|
|
404
483
|
validateSnak(snak);
|
|
405
484
|
return this.#mutate(id, edit, (entity) => {
|
|
406
485
|
const statement = locateStatement(entity, statementId).statement;
|
|
486
|
+
statement.text = text;
|
|
407
487
|
const property = snak.property;
|
|
408
488
|
if (!statement.qualifiers[property]) {
|
|
409
489
|
statement.qualifiers[property] = [];
|
|
@@ -413,9 +493,11 @@ export class TaprootRepository {
|
|
|
413
493
|
return entity;
|
|
414
494
|
});
|
|
415
495
|
}
|
|
416
|
-
async removeQualifier(id, statementId, property, ordinal, edit) {
|
|
496
|
+
async removeQualifier(id, statementId, property, ordinal, text, edit) {
|
|
497
|
+
assertAuthoredStatementText(text, statementId);
|
|
417
498
|
return this.#mutate(id, edit, (entity) => {
|
|
418
499
|
const statement = locateStatement(entity, statementId).statement;
|
|
500
|
+
statement.text = text;
|
|
419
501
|
const snaks = statement.qualifiers[property];
|
|
420
502
|
if (!snaks?.[ordinal])
|
|
421
503
|
throw new InvalidStatementError('Qualifier does not exist');
|
|
@@ -427,16 +509,21 @@ export class TaprootRepository {
|
|
|
427
509
|
return entity;
|
|
428
510
|
});
|
|
429
511
|
}
|
|
430
|
-
async addReference(id, statementId, reference, edit) {
|
|
512
|
+
async addReference(id, statementId, reference, text, edit) {
|
|
513
|
+
assertAuthoredStatementText(text, statementId);
|
|
431
514
|
return this.#mutate(id, edit, (entity) => {
|
|
432
|
-
locateStatement(entity, statementId).statement
|
|
515
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
516
|
+
statement.text = text;
|
|
517
|
+
statement.references.push(structuredClone(reference));
|
|
433
518
|
return entity;
|
|
434
519
|
});
|
|
435
520
|
}
|
|
436
|
-
async removeReference(id, statementId, hash, edit) {
|
|
521
|
+
async removeReference(id, statementId, hash, text, edit) {
|
|
522
|
+
assertAuthoredStatementText(text, statementId);
|
|
437
523
|
return this.#mutate(id, edit, (entity) => {
|
|
438
|
-
const
|
|
439
|
-
|
|
524
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
525
|
+
statement.text = text;
|
|
526
|
+
const references = statement.references;
|
|
440
527
|
const index = references.findIndex((reference) => reference.hash === hash);
|
|
441
528
|
if (index < 0)
|
|
442
529
|
throw new InvalidStatementError(`Reference ${hash} does not exist`);
|
|
@@ -444,10 +531,12 @@ export class TaprootRepository {
|
|
|
444
531
|
return entity;
|
|
445
532
|
});
|
|
446
533
|
}
|
|
447
|
-
async replaceReference(id, statementId, hash, reference, edit) {
|
|
534
|
+
async replaceReference(id, statementId, hash, reference, text, edit) {
|
|
535
|
+
assertAuthoredStatementText(text, statementId);
|
|
448
536
|
return this.#mutate(id, edit, (entity) => {
|
|
449
|
-
const
|
|
450
|
-
|
|
537
|
+
const statement = locateStatement(entity, statementId).statement;
|
|
538
|
+
statement.text = text;
|
|
539
|
+
const references = statement.references;
|
|
451
540
|
const index = references.findIndex((item) => item.hash === hash);
|
|
452
541
|
if (index < 0)
|
|
453
542
|
throw new InvalidStatementError(`Reference ${hash} does not exist`);
|
|
@@ -462,6 +551,7 @@ export class TaprootRepository {
|
|
|
462
551
|
}
|
|
463
552
|
const succeeded = new Map();
|
|
464
553
|
const failed = [];
|
|
554
|
+
let authorizationContext = options.metadata?.authorizationContext;
|
|
465
555
|
let pending = values
|
|
466
556
|
.map((entity, index) => ({ entity, index }))
|
|
467
557
|
.sort((a, b) => Number(a.entity.type !== 'property') -
|
|
@@ -471,7 +561,20 @@ export class TaprootRepository {
|
|
|
471
561
|
let progress = false;
|
|
472
562
|
for (const entry of pending) {
|
|
473
563
|
try {
|
|
474
|
-
|
|
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
|
+
};
|
|
475
578
|
progress = true;
|
|
476
579
|
}
|
|
477
580
|
catch (error) {
|
|
@@ -518,11 +621,31 @@ export class TaprootRepository {
|
|
|
518
621
|
}
|
|
519
622
|
async #importOne(entity, options) {
|
|
520
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
|
+
}
|
|
521
640
|
try {
|
|
522
641
|
const current = await this.getEntity(entity.id);
|
|
523
642
|
return await this.replaceEntity(entity.id, entity, {
|
|
524
643
|
expectedRevision: current.entity.lastrevid,
|
|
644
|
+
statementTexts: authoredStatementTexts(entity),
|
|
525
645
|
...options.metadata,
|
|
646
|
+
...(options.authorizations?.[entity.id]
|
|
647
|
+
? { authorization: options.authorizations[entity.id] }
|
|
648
|
+
: {}),
|
|
526
649
|
});
|
|
527
650
|
}
|
|
528
651
|
catch (error) {
|
|
@@ -530,7 +653,12 @@ export class TaprootRepository {
|
|
|
530
653
|
throw error;
|
|
531
654
|
}
|
|
532
655
|
}
|
|
533
|
-
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
|
+
});
|
|
534
662
|
}
|
|
535
663
|
async applyCommands(id, commands, edit) {
|
|
536
664
|
if (!commands.length)
|
|
@@ -721,12 +849,48 @@ export class TaprootRepository {
|
|
|
721
849
|
deletedAt: stored.deletedAt,
|
|
722
850
|
redirectTo: stored.redirectTo,
|
|
723
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;
|
|
724
866
|
const statements = [
|
|
725
867
|
this.#db
|
|
726
868
|
.prepare(`DELETE FROM taproot_terms WHERE entity_id = ?`)
|
|
727
869
|
.bind(id),
|
|
728
870
|
this.#termsInsert(stored.entity),
|
|
729
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),
|
|
730
894
|
...patch.statements,
|
|
731
895
|
...this.#ownershipPatch(id, actual, expected).statements,
|
|
732
896
|
];
|
|
@@ -738,6 +902,8 @@ export class TaprootRepository {
|
|
|
738
902
|
this.#emitObservation('repair', started, 'error', id, stored.entity.lastrevid, cause);
|
|
739
903
|
if (isEventIdUniqueError(cause))
|
|
740
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 });
|
|
741
907
|
throw cause;
|
|
742
908
|
}
|
|
743
909
|
const after = await this.inspectEntityIntegrity(id);
|
|
@@ -752,6 +918,8 @@ export class TaprootRepository {
|
|
|
752
918
|
async #create(entity, metadata, allocated, eventType) {
|
|
753
919
|
const started = performance.now();
|
|
754
920
|
validateEntity(entity);
|
|
921
|
+
if (metadata.authorization || metadata.authorizationContext)
|
|
922
|
+
await this.#preauthorizeCanonicalWrite(entity.id, metadata.authorization, metadata.authorizationContext);
|
|
755
923
|
if (await this.#loadRow(entity.id))
|
|
756
924
|
throw new EntityAlreadyExistsError(`Entity ${entity.id} already exists`);
|
|
757
925
|
await this.#validatePropertyDatatypes(entity);
|
|
@@ -759,6 +927,9 @@ export class TaprootRepository {
|
|
|
759
927
|
const json = exportEntityJson(entity, this.#options.maxEntityBytes);
|
|
760
928
|
const lifecycle = { deletedAt: null, redirectTo: null };
|
|
761
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;
|
|
762
933
|
const newQuads = this.#lifecycleQuads(entity, lifecycle);
|
|
763
934
|
const marker = revisionQuad(entity, this.#options);
|
|
764
935
|
const patch = this.#preparePatch({ forbid: [marker], insert: newQuads });
|
|
@@ -777,7 +948,9 @@ export class TaprootRepository {
|
|
|
777
948
|
}
|
|
778
949
|
statements.push(this.#db
|
|
779
950
|
.prepare(`INSERT INTO taproot_entities(entity_id, entity_type, datatype, revision, entity_json, modified_at) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
780
|
-
.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));
|
|
781
954
|
const patchOffset = statements.length;
|
|
782
955
|
statements.push(...patch.statements);
|
|
783
956
|
const ownership = this.#ownershipPatch(entity.id, [], newQuads);
|
|
@@ -796,6 +969,12 @@ export class TaprootRepository {
|
|
|
796
969
|
},
|
|
797
970
|
eventId: context.eventId,
|
|
798
971
|
contentHash: context.contentHash,
|
|
972
|
+
...(authorization
|
|
973
|
+
? {
|
|
974
|
+
authorizationRevision: authorization.authorizationRevision,
|
|
975
|
+
searchGeneration: authorization.searchGeneration,
|
|
976
|
+
}
|
|
977
|
+
: {}),
|
|
799
978
|
};
|
|
800
979
|
this.#emitObservation(eventType, started, 'success', entity.id, entity.lastrevid);
|
|
801
980
|
return result;
|
|
@@ -812,13 +991,17 @@ export class TaprootRepository {
|
|
|
812
991
|
}
|
|
813
992
|
if (isEventIdUniqueError(mapped))
|
|
814
993
|
throw new RevisionConflictError('Generated audit event id already exists', { cause: mapped });
|
|
815
|
-
if (
|
|
816
|
-
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 });
|
|
817
998
|
throw mapped;
|
|
818
999
|
}
|
|
819
1000
|
}
|
|
820
1001
|
async #mutate(id, edit, transform, lifecycleOverride, eventType = 'update') {
|
|
821
1002
|
const started = performance.now();
|
|
1003
|
+
if (edit.authorization || edit.authorizationContext)
|
|
1004
|
+
await this.#preauthorizeCanonicalWrite(id, edit.authorization, edit.authorizationContext, edit.expectedRevision);
|
|
822
1005
|
const row = await this.#loadRow(id);
|
|
823
1006
|
if (!row)
|
|
824
1007
|
throw new EntityNotFoundError(`Entity ${id} was not found`);
|
|
@@ -850,6 +1033,9 @@ export class TaprootRepository {
|
|
|
850
1033
|
(await revisionContentHash(row.entity_json, oldLifecycle));
|
|
851
1034
|
const newLifecycle = lifecycleOverride ?? oldLifecycle;
|
|
852
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;
|
|
853
1039
|
const oldQuads = this.#lifecycleQuads(stored.entity, oldLifecycle);
|
|
854
1040
|
const newQuads = this.#lifecycleQuads(next, newLifecycle);
|
|
855
1041
|
const patch = this.#preparePatch({
|
|
@@ -863,12 +1049,14 @@ export class TaprootRepository {
|
|
|
863
1049
|
.bind(next.type === 'property' ? next.datatype : null, next.lastrevid, json, next.modified, newLifecycle.deletedAt, newLifecycle.redirectTo, id, previous),
|
|
864
1050
|
this.#assertion(`EXISTS (SELECT 1 FROM taproot_entities WHERE entity_id = ? AND revision = ?)`, id, next.lastrevid),
|
|
865
1051
|
this.#revisionInsert(next, json, edit, context),
|
|
866
|
-
this.#auditInsert(next, edit, context),
|
|
1052
|
+
this.#auditInsert(next, edit, context, authorization),
|
|
867
1053
|
this.#db
|
|
868
1054
|
.prepare('DELETE FROM taproot_terms WHERE entity_id = ?')
|
|
869
1055
|
.bind(id),
|
|
870
1056
|
this.#termsInsert(next),
|
|
871
1057
|
];
|
|
1058
|
+
if (authorization)
|
|
1059
|
+
statements.push(...this.#authorizationWriteStatements(next, newLifecycle, context, authorization, previous));
|
|
872
1060
|
const patchOffset = statements.length;
|
|
873
1061
|
statements.push(...patch.statements);
|
|
874
1062
|
const ownership = this.#ownershipPatch(id, oldQuads, newQuads);
|
|
@@ -887,6 +1075,12 @@ export class TaprootRepository {
|
|
|
887
1075
|
},
|
|
888
1076
|
eventId: context.eventId,
|
|
889
1077
|
contentHash: context.contentHash,
|
|
1078
|
+
...(authorization
|
|
1079
|
+
? {
|
|
1080
|
+
authorizationRevision: authorization.authorizationRevision,
|
|
1081
|
+
searchGeneration: authorization.searchGeneration,
|
|
1082
|
+
}
|
|
1083
|
+
: {}),
|
|
890
1084
|
};
|
|
891
1085
|
this.#emitObservation(eventType, started, 'success', id, next.lastrevid);
|
|
892
1086
|
return result;
|
|
@@ -907,6 +1101,295 @@ export class TaprootRepository {
|
|
|
907
1101
|
throw mapped;
|
|
908
1102
|
}
|
|
909
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
|
+
}
|
|
910
1393
|
#preparePatch(patch) {
|
|
911
1394
|
try {
|
|
912
1395
|
return prepareQuadPatch(this.#db, patch);
|
|
@@ -1037,13 +1520,24 @@ export class TaprootRepository {
|
|
|
1037
1520
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1038
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);
|
|
1039
1522
|
}
|
|
1040
|
-
#auditInsert(entity, metadata, context) {
|
|
1523
|
+
#auditInsert(entity, metadata, context, authorization) {
|
|
1041
1524
|
return this.#db
|
|
1042
1525
|
.prepare(`INSERT INTO taproot_audit_events(
|
|
1043
1526
|
event_id, entity_id, revision, event_type, attribution_json, edit_summary,
|
|
1044
1527
|
tags_json, request_id, content_hash, parent_hash, details_json, created_at
|
|
1045
1528
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1046
|
-
.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);
|
|
1047
1541
|
}
|
|
1048
1542
|
async #writeContext(entity, json, metadata, parentHash, eventType, lifecycle) {
|
|
1049
1543
|
const attribution = normalizeAttribution(metadata);
|
|
@@ -1227,6 +1721,26 @@ function locateStatement(entity, id) {
|
|
|
1227
1721
|
}
|
|
1228
1722
|
throw new InvalidStatementError(`Statement ${id} does not exist`);
|
|
1229
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
|
+
}
|
|
1230
1744
|
function collectSnak(snak, used) {
|
|
1231
1745
|
const current = used.get(snak.property);
|
|
1232
1746
|
if (current && current !== snak.datatype)
|
|
@@ -1234,6 +1748,15 @@ function collectSnak(snak, used) {
|
|
|
1234
1748
|
used.set(snak.property, snak.datatype);
|
|
1235
1749
|
}
|
|
1236
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);
|
|
1237
1760
|
switch (command.type) {
|
|
1238
1761
|
case 'set-label':
|
|
1239
1762
|
entity.labels[command.language] = {
|
|
@@ -1302,10 +1825,13 @@ function applyEntityCommand(entity, command) {
|
|
|
1302
1825
|
case 'set-statement-rank':
|
|
1303
1826
|
locateStatement(entity, command.statementId).statement.rank =
|
|
1304
1827
|
command.rank;
|
|
1828
|
+
locateStatement(entity, command.statementId).statement.text =
|
|
1829
|
+
command.text;
|
|
1305
1830
|
break;
|
|
1306
1831
|
case 'add-qualifier': {
|
|
1307
1832
|
validateSnak(command.snak);
|
|
1308
1833
|
const statement = locateStatement(entity, command.statementId).statement;
|
|
1834
|
+
statement.text = command.text;
|
|
1309
1835
|
const property = command.snak.property;
|
|
1310
1836
|
if (!statement.qualifiers[property]) {
|
|
1311
1837
|
statement.qualifiers[property] = [];
|
|
@@ -1316,6 +1842,7 @@ function applyEntityCommand(entity, command) {
|
|
|
1316
1842
|
}
|
|
1317
1843
|
case 'remove-qualifier': {
|
|
1318
1844
|
const statement = locateStatement(entity, command.statementId).statement;
|
|
1845
|
+
statement.text = command.text;
|
|
1319
1846
|
const snaks = statement.qualifiers[command.property];
|
|
1320
1847
|
if (!snaks?.[command.ordinal])
|
|
1321
1848
|
throw new InvalidStatementError('Qualifier does not exist');
|
|
@@ -1326,12 +1853,16 @@ function applyEntityCommand(entity, command) {
|
|
|
1326
1853
|
}
|
|
1327
1854
|
break;
|
|
1328
1855
|
}
|
|
1329
|
-
case 'add-reference':
|
|
1330
|
-
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));
|
|
1331
1860
|
break;
|
|
1861
|
+
}
|
|
1332
1862
|
case 'replace-reference': {
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1863
|
+
const statement = locateStatement(entity, command.statementId).statement;
|
|
1864
|
+
statement.text = command.text;
|
|
1865
|
+
const references = statement.references;
|
|
1335
1866
|
const index = references.findIndex(({ hash }) => hash === command.hash);
|
|
1336
1867
|
if (index < 0)
|
|
1337
1868
|
throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
|
|
@@ -1339,8 +1870,9 @@ function applyEntityCommand(entity, command) {
|
|
|
1339
1870
|
break;
|
|
1340
1871
|
}
|
|
1341
1872
|
case 'remove-reference': {
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1873
|
+
const statement = locateStatement(entity, command.statementId).statement;
|
|
1874
|
+
statement.text = command.text;
|
|
1875
|
+
const references = statement.references;
|
|
1344
1876
|
const index = references.findIndex(({ hash }) => hash === command.hash);
|
|
1345
1877
|
if (index < 0)
|
|
1346
1878
|
throw new InvalidStatementError(`Reference ${command.hash} does not exist`);
|
|
@@ -1365,10 +1897,10 @@ function isUniqueEntityError(cause) {
|
|
|
1365
1897
|
return /UNIQUE constraint failed: taproot_entities\.entity_id/iu.test(cause.message);
|
|
1366
1898
|
}
|
|
1367
1899
|
function isRevisionUniqueError(cause) {
|
|
1368
|
-
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));
|
|
1369
1901
|
}
|
|
1370
1902
|
function isEventIdUniqueError(cause) {
|
|
1371
|
-
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));
|
|
1372
1904
|
}
|
|
1373
1905
|
function normalizeAttribution(metadata) {
|
|
1374
1906
|
if (metadata.actor !== undefined && typeof metadata.actor !== 'string')
|
|
@@ -1481,6 +2013,19 @@ function sameQuads(left, right) {
|
|
|
1481
2013
|
const b = [...new Set(right.map(quadKeyValue))].sort();
|
|
1482
2014
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1483
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
|
+
}
|
|
1484
2029
|
function toError(error) {
|
|
1485
2030
|
return error instanceof Error ? error : new Error(String(error));
|
|
1486
2031
|
}
|