@haverstack/core 0.34.0 → 0.37.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/README.md +1 -1
- package/dist/access.d.ts +39 -34
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +94 -68
- package/dist/access.js.map +1 -1
- package/dist/changes.d.ts +22 -33
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +34 -48
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +7 -0
- package/dist/combine.js.map +1 -1
- package/dist/grants.d.ts +78 -33
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +164 -72
- package/dist/grants.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query-validation.d.ts +20 -1
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +107 -3
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +71 -63
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +129 -85
- package/dist/record-changes.js.map +1 -1
- package/dist/scoped-stack.d.ts +127 -117
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +222 -189
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack.d.ts +180 -160
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +293 -229
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +3 -8
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +42 -49
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +128 -101
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +0 -2
- package/dist/validate.js.map +1 -1
- package/dist/wire-record.d.ts.map +1 -1
- package/dist/wire-record.js.map +1 -1
- package/package.json +1 -1
package/dist/stack.js
CHANGED
|
@@ -22,8 +22,8 @@ import { compareRecordedAttachments } from './attachment-download.js';
|
|
|
22
22
|
import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
|
|
23
23
|
import { SYSTEM_TYPES } from './types.js';
|
|
24
24
|
import { StackClosedError, StackConflictError, StackMigrationError, StackNotFoundError, StackPermissionError, StackQueryError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
|
|
25
|
-
import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidJournalQuery, assertValidSort, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
|
|
26
|
-
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, matchesGrantTarget, validateGrantTarget, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
|
|
25
|
+
import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
|
|
26
|
+
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
|
|
27
27
|
import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
|
|
28
28
|
import { assertAttachmentSize, assertContentSize } from './limits.js';
|
|
29
29
|
import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
|
|
@@ -433,26 +433,27 @@ export class Stack {
|
|
|
433
433
|
if (!type) {
|
|
434
434
|
throw new StackQueryError(`Unknown type: "${typeId}". Call defineType() first.`);
|
|
435
435
|
}
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// (
|
|
442
|
-
//
|
|
443
|
-
// version bump and no change event. createdAt drives updatedAt and
|
|
444
|
-
// unlistedAt below, so one copy taken here covers all three.
|
|
445
|
-
// `instanceof Date`, not `!== undefined`: this runs before the error
|
|
446
|
-
// block below, so a non-Date reaching here off the wire would throw a
|
|
447
|
-
// raw TypeError out of .getTime() before validateClockField() could
|
|
448
|
-
// report it. Falling back keeps this line total; the error it recorded
|
|
449
|
-
// still throws below, so the fallback value is never actually stored.
|
|
436
|
+
// Copied, never aliased: an import loop that reuses one Date across rows
|
|
437
|
+
// (`d.setTime(...)` per record) would otherwise retro-edit every record
|
|
438
|
+
// it had already written, with no version bump and no change event.
|
|
439
|
+
// `instanceof Date`, not `!== undefined`, because this runs ahead of the
|
|
440
|
+
// error block below and .getTime() on a non-Date off the wire would
|
|
441
|
+
// throw before validateClockField() could report it; the fallback value
|
|
442
|
+
// never reaches storage, since that recorded error still throws.
|
|
450
443
|
const createdAt = opts.createdAt instanceof Date ? new Date(opts.createdAt.getTime()) : new Date();
|
|
451
444
|
const updatedAt = opts.updatedAt instanceof Date ? new Date(opts.updatedAt.getTime()) : createdAt;
|
|
445
|
+
// Ahead of every other check on these two lists: a caller that named
|
|
446
|
+
// the wrong surface asked for something this layer does not offer, and
|
|
447
|
+
// reporting its grantee as a malformed relationship target would
|
|
448
|
+
// describe the mistake as the wrong kind of problem.
|
|
449
|
+
assertDataAssociations(opts.associations ?? [], 'associations');
|
|
450
|
+
assertAuthorityAssociations(opts.permissions ?? [], 'permissions');
|
|
452
451
|
const errors = [
|
|
453
452
|
...validateReservedKeys(content),
|
|
454
453
|
...validateContentKeys(content),
|
|
455
454
|
...validateContent(content, type.schema),
|
|
455
|
+
...validateGrantee(typeId, content),
|
|
456
|
+
...validateAssociations(opts.permissions, 'permissions'),
|
|
456
457
|
...validatePermissions(opts.permissions),
|
|
457
458
|
...validateAssociations(opts.associations),
|
|
458
459
|
...validateClockField(opts.createdAt, 'createdAt'),
|
|
@@ -475,8 +476,8 @@ export class Stack {
|
|
|
475
476
|
await this.checkBindingsOnCreate(typeId, content);
|
|
476
477
|
if (opts.id !== undefined) {
|
|
477
478
|
validateRecordId(opts.id);
|
|
478
|
-
// Only when both are explicit: an `id` alone
|
|
479
|
-
//
|
|
479
|
+
// Only when both are explicit: an `id` alone is a pure position
|
|
480
|
+
// choice, with no second timestamp to agree with.
|
|
480
481
|
if (opts.createdAt !== undefined) {
|
|
481
482
|
validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
|
|
482
483
|
}
|
|
@@ -491,31 +492,25 @@ export class Stack {
|
|
|
491
492
|
const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
|
|
492
493
|
? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
|
|
493
494
|
: opts.associations;
|
|
494
|
-
// createdAt
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
// clamps to "now" — generateId()'s monotonic floor would otherwise
|
|
500
|
-
// silently pull a deliberately historical id forward once this process
|
|
501
|
-
// has minted any live id past it. The no-createdAt path keeps
|
|
502
|
-
// generateId(), unaffected and still monotonic-safe.
|
|
495
|
+
// createdAt drives the id, so the two agree by construction rather than
|
|
496
|
+
// by coincidence. An explicit one mints via generateIdForTimestamp(),
|
|
497
|
+
// which never clamps to "now": generateId()'s monotonic floor would
|
|
498
|
+
// otherwise pull a deliberately historical id forward once this process
|
|
499
|
+
// has minted any live id past it.
|
|
503
500
|
const id = opts.id ??
|
|
504
501
|
(opts.createdAt !== undefined
|
|
505
502
|
? generateIdForTimestamp(createdAt.getTime())
|
|
506
503
|
: generateId(createdAt.getTime()));
|
|
507
504
|
// An id field a caller supplies is a value or it is absent — never the
|
|
508
|
-
// empty string, which names nobody. Refused rather than dropped
|
|
509
|
-
//
|
|
510
|
-
// fixed for, one layer up.
|
|
505
|
+
// empty string, which names nobody. Refused rather than dropped, so the
|
|
506
|
+
// caller is never silently ignored.
|
|
511
507
|
for (const field of ['entityId', 'appId', 'principalId']) {
|
|
512
508
|
if (opts[field] === '') {
|
|
513
509
|
throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
|
|
514
510
|
}
|
|
515
511
|
}
|
|
516
512
|
// Every create naming a parent owes the reference check, whether or not
|
|
517
|
-
// it supplied an id: a destination a caller names has to
|
|
518
|
-
// exists. This is the read an ordinary create used to skip.
|
|
513
|
+
// it supplied an id: a destination a caller names has to exist.
|
|
519
514
|
if (opts.parentId !== undefined)
|
|
520
515
|
await this.assertParentExists(id, opts.parentId);
|
|
521
516
|
const record = {
|
|
@@ -525,10 +520,8 @@ export class Stack {
|
|
|
525
520
|
updatedAt,
|
|
526
521
|
content,
|
|
527
522
|
version: 1,
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
// falsy value could mean. Presence says that outright instead of
|
|
531
|
-
// relying on it.
|
|
523
|
+
// Presence, not truthiness: '' is refused above, so absence is the
|
|
524
|
+
// only thing a falsy value could mean here.
|
|
532
525
|
...(opts.parentId !== undefined && { parentId: opts.parentId }),
|
|
533
526
|
...(opts.entityId !== undefined && { entityId: opts.entityId }),
|
|
534
527
|
...(opts.appId !== undefined && { appId: opts.appId }),
|
|
@@ -588,20 +581,14 @@ export class Stack {
|
|
|
588
581
|
return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
|
|
589
582
|
}
|
|
590
583
|
/**
|
|
591
|
-
* Apply a change set —
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* `unlisted: false` and `parentId: null` are changes.
|
|
584
|
+
* Apply a change set as one atomic write — see the StackClient
|
|
585
|
+
* declaration above for what it accepts. Atomicity is what it is for: a
|
|
586
|
+
* publish is one act, so nothing it names may half-land.
|
|
595
587
|
*
|
|
596
|
-
* Every key present is checked against the record as it stands, and a
|
|
597
|
-
*
|
|
598
|
-
* the record
|
|
599
|
-
*
|
|
600
|
-
* never reported as moving it.
|
|
601
|
-
*
|
|
602
|
-
* Content is validated against the record's *current* stored type and
|
|
603
|
-
* `typeId` never changes (see docs/spec/data-model.md § Type
|
|
604
|
-
* migrations). See docs/spec/data-model.md § Mutations.
|
|
588
|
+
* Every key present is checked against the record as it stands, and a set
|
|
589
|
+
* already satisfied in all of them writes nothing. Content is validated
|
|
590
|
+
* against the record's *current* stored type; `typeId` never changes here.
|
|
591
|
+
* See docs/spec/data-model.md § Mutations.
|
|
605
592
|
*/
|
|
606
593
|
async mutate(id, changes, opts = {}) {
|
|
607
594
|
this.assertOpen();
|
|
@@ -624,23 +611,31 @@ export class Stack {
|
|
|
624
611
|
// writes nothing.
|
|
625
612
|
if (ops.length === 0)
|
|
626
613
|
return existing;
|
|
627
|
-
// A change set
|
|
628
|
-
// rule associate()/dissociate() follow
|
|
629
|
-
// travels as an explicit opt rather than a
|
|
630
|
-
// non-bumping write never touches
|
|
631
|
-
// See docs/spec/versioning.md § Version history.
|
|
614
|
+
// A change set naming only aspects the journal already keeps in full
|
|
615
|
+
// doesn't bump — the same rule associate()/dissociate() follow
|
|
616
|
+
// unconditionally. Its actor travels as an explicit opt rather than a
|
|
617
|
+
// record stamp, since a non-bumping write never touches
|
|
618
|
+
// updatedBy/updatedVia. See docs/spec/versioning.md § Version history.
|
|
632
619
|
const bumps = bumpsVersion(ops);
|
|
633
620
|
// Computed against the same before/after changeSetOps compared, so
|
|
634
|
-
// whether associate/dissociate appear in `ops` and what
|
|
635
|
-
// never disagree.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
621
|
+
// whether associate/dissociate/permissions appear in `ops` and what
|
|
622
|
+
// the journal lists can never disagree. Both halves of the partition
|
|
623
|
+
// produce the same tagged edits and travel as one list: the journal's
|
|
624
|
+
// argument is prior state, and an ACL element's is no different from a
|
|
625
|
+
// tag's. See docs/spec/events.md § The event shape.
|
|
626
|
+
const assocDelta = [
|
|
627
|
+
...(changes.associations
|
|
628
|
+
? associationDelta(existing.associations ?? [], changes.associations)
|
|
629
|
+
: []),
|
|
630
|
+
...(changes.permissions
|
|
631
|
+
? associationDelta(existing.permissions ?? [], changes.permissions)
|
|
632
|
+
: []),
|
|
633
|
+
];
|
|
639
634
|
const previousParentId = existing.parentId ?? null;
|
|
640
635
|
const change = new PendingChange(ops, {
|
|
641
636
|
actor: Stack.actorFrom(opts),
|
|
642
637
|
...(ops.includes('reparent') && { previousParentId }),
|
|
643
|
-
...(assocDelta && { associations: assocDelta }),
|
|
638
|
+
...(assocDelta.length && { associations: assocDelta }),
|
|
644
639
|
});
|
|
645
640
|
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
|
|
646
641
|
...(bumps
|
|
@@ -676,6 +671,15 @@ export class Stack {
|
|
|
676
671
|
*/
|
|
677
672
|
async validateChangeSet(id, existing, changes) {
|
|
678
673
|
const { contentPatch, permissions, associations, parentId } = changes;
|
|
674
|
+
// Each key replaces only within its own domain. Asked before the
|
|
675
|
+
// shape checks below so a misrouted element is reported as the wrong
|
|
676
|
+
// surface rather than as a malformed value of the right one, and
|
|
677
|
+
// asked here rather than in `ScopedStack` so an unscoped `Stack`, an
|
|
678
|
+
// import and a server mapping a request body are all held to it.
|
|
679
|
+
if (associations)
|
|
680
|
+
assertDataAssociations(associations, 'associations');
|
|
681
|
+
if (permissions)
|
|
682
|
+
assertAuthorityAssociations(permissions, 'permissions');
|
|
679
683
|
const errors = [
|
|
680
684
|
...(contentPatch
|
|
681
685
|
? [
|
|
@@ -684,7 +688,9 @@ export class Stack {
|
|
|
684
688
|
...validateContentKeys(contentPatch),
|
|
685
689
|
]
|
|
686
690
|
: []),
|
|
687
|
-
...(permissions
|
|
691
|
+
...(permissions
|
|
692
|
+
? [...validateAssociations(permissions, 'permissions'), ...validatePermissions(permissions)]
|
|
693
|
+
: []),
|
|
688
694
|
...(associations ? validateAssociations(associations) : []),
|
|
689
695
|
];
|
|
690
696
|
if (errors.length > 0)
|
|
@@ -700,7 +706,10 @@ export class Stack {
|
|
|
700
706
|
throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
|
|
701
707
|
}
|
|
702
708
|
merged = applyMergePatch(existing.content, contentPatch);
|
|
703
|
-
const contentErrors =
|
|
709
|
+
const contentErrors = [
|
|
710
|
+
...validateContent(merged, type.schema),
|
|
711
|
+
...validateGrantee(existing.typeId, merged),
|
|
712
|
+
];
|
|
704
713
|
if (contentErrors.length > 0)
|
|
705
714
|
throw new StackValidationError(contentErrors);
|
|
706
715
|
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
@@ -727,22 +736,15 @@ export class Stack {
|
|
|
727
736
|
return merged;
|
|
728
737
|
}
|
|
729
738
|
/**
|
|
730
|
-
* Refuse a write that would leave a `_group` Record with no `admin` on
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
* That framing is the whole of the self-removal question: an `admin`
|
|
737
|
-
* removing themselves passes while another remains and is refused when
|
|
738
|
-
* they are the last, and neither case needs to know whose entry went.
|
|
739
|
+
* Refuse a write that would leave a `_group` Record with no `admin` on its
|
|
740
|
+
* roster. Asked of the roster the write would *produce*, which is the
|
|
741
|
+
* whole of the self-removal question: an admin removing themselves passes
|
|
742
|
+
* while another remains and is refused when they are the last, without
|
|
743
|
+
* either case naming who is going.
|
|
739
744
|
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
* requester — the stack owner included, who would otherwise be the hole
|
|
744
|
-
* that stops anything downstream relying on the invariant.
|
|
745
|
-
* See docs/spec/identity.md § Group.
|
|
745
|
+
* An integrity constraint on the Record, not a permission question, so it
|
|
746
|
+
* lives here rather than in `ScopedStack` and binds every requester — the
|
|
747
|
+
* stack owner included. See docs/spec/identity.md § Group.
|
|
746
748
|
*/
|
|
747
749
|
assertGroupAdminRemains(record, next) {
|
|
748
750
|
if (!isGroupRecord(record))
|
|
@@ -753,25 +755,19 @@ export class Stack {
|
|
|
753
755
|
'least one `admin` relationship association. Name the incoming admin in the same write.');
|
|
754
756
|
}
|
|
755
757
|
/**
|
|
756
|
-
* Add an association to a record
|
|
757
|
-
*
|
|
758
|
-
* order, so it needs neither the OCC `ifVersion` guards content,
|
|
759
|
-
* `parentId` and `permissions`, nor the rollback history that guards.
|
|
760
|
-
* See docs/spec/versioning.md § Version history.
|
|
758
|
+
* Add an association to a record — no-bump and no snapshot, per the
|
|
759
|
+
* StackClient declaration above.
|
|
761
760
|
*
|
|
762
761
|
* An association the record already holds, saying the same thing, is a
|
|
763
762
|
* no-op; one matching an existing association's identity but naming a
|
|
764
763
|
* different `attachmentRecordId` — or none, which clears the one stored —
|
|
765
|
-
* re-points
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
* overwrote is kept only by the journal, on that entry's `repoint`. See
|
|
769
|
-
* docs/spec/events.md § The event shape and docs/spec/journal.md
|
|
770
|
-
* § The entry. Returns the record as it now stands — unchanged on a
|
|
771
|
-
* no-op.
|
|
764
|
+
* re-points it in place rather than adding a second reference to the same
|
|
765
|
+
* file. What a re-point overwrote is kept only by the journal, on that
|
|
766
|
+
* entry's `repoint`. See docs/spec/journal.md § The entry.
|
|
772
767
|
*/
|
|
773
768
|
async associate(id, association, opts = {}) {
|
|
774
769
|
this.assertOpen();
|
|
770
|
+
assertDataAssociations([association], 'associate()');
|
|
775
771
|
const errors = validateAssociation(association);
|
|
776
772
|
if (errors.length > 0)
|
|
777
773
|
throw new StackValidationError(errors);
|
|
@@ -797,16 +793,15 @@ export class Stack {
|
|
|
797
793
|
return updated;
|
|
798
794
|
}
|
|
799
795
|
/**
|
|
800
|
-
* Remove an association from a record.
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
* journal is where it survives. See docs/spec/events.md § The event
|
|
806
|
-
* shape. Returns the record as it now stands — unchanged on a no-op.
|
|
796
|
+
* Remove an association from a record — see associate(). Matched by kind,
|
|
797
|
+
* label and payload; a no-op if not found. The emitted event names the
|
|
798
|
+
* association by identity only, since an attachment's
|
|
799
|
+
* `attachmentRecordId` describes nothing current once it is gone; the
|
|
800
|
+
* journal is where it survives.
|
|
807
801
|
*/
|
|
808
802
|
async dissociate(id, association, opts = {}) {
|
|
809
803
|
this.assertOpen();
|
|
804
|
+
assertDataAssociations([association], 'dissociate()');
|
|
810
805
|
const errors = validateAssociation(association);
|
|
811
806
|
if (errors.length > 0)
|
|
812
807
|
throw new StackValidationError(errors);
|
|
@@ -836,6 +831,73 @@ export class Stack {
|
|
|
836
831
|
this.announce(change, updated);
|
|
837
832
|
return updated;
|
|
838
833
|
}
|
|
834
|
+
/**
|
|
835
|
+
* Extend who reaches a record by one element. The spelling that survives
|
|
836
|
+
* two admins sharing a record at once: a `permissions` key write replaces
|
|
837
|
+
* the whole set, so the later of two concurrent ones drops what the
|
|
838
|
+
* earlier granted. No-bump, like associate(); an element the record
|
|
839
|
+
* already carries is a no-op.
|
|
840
|
+
*/
|
|
841
|
+
async grantAccess(id, permission, opts = {}) {
|
|
842
|
+
this.assertOpen();
|
|
843
|
+
assertAuthorityAssociations([permission], 'grantAccess()');
|
|
844
|
+
const errors = validateAssociation(permission, 'permission');
|
|
845
|
+
if (errors.length > 0)
|
|
846
|
+
throw new StackValidationError(errors);
|
|
847
|
+
const existing = await this.requireRecord(id);
|
|
848
|
+
const current = existing.permissions ?? [];
|
|
849
|
+
if (current.some((p) => associationEqual(p, permission)))
|
|
850
|
+
return existing;
|
|
851
|
+
this.assertPermissionSet([...current, permission]);
|
|
852
|
+
const change = new PendingChange('permissions', {
|
|
853
|
+
actor: Stack.actorFrom(opts),
|
|
854
|
+
associations: [{ op: 'add', association: permission }],
|
|
855
|
+
});
|
|
856
|
+
const updated = await this.adapter.associate(id, permission, { journal: change.journal });
|
|
857
|
+
this.announce(change, updated);
|
|
858
|
+
return updated;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Withdraw one element of who reaches a record — see grantAccess() for
|
|
862
|
+
* why this is a verb rather than a key write. An element the record does
|
|
863
|
+
* not carry is a no-op. Returns the record as it now stands.
|
|
864
|
+
*/
|
|
865
|
+
async revokeAccess(id, permission, opts = {}) {
|
|
866
|
+
this.assertOpen();
|
|
867
|
+
assertAuthorityAssociations([permission], 'revokeAccess()');
|
|
868
|
+
const errors = validateAssociation(permission, 'permission');
|
|
869
|
+
if (errors.length > 0)
|
|
870
|
+
throw new StackValidationError(errors);
|
|
871
|
+
const existing = await this.requireRecord(id);
|
|
872
|
+
const current = existing.permissions ?? [];
|
|
873
|
+
const matched = current.find((p) => associationEqual(p, permission));
|
|
874
|
+
if (!matched)
|
|
875
|
+
return existing;
|
|
876
|
+
this.assertPermissionSet(current.filter((p) => !associationEqual(p, permission)));
|
|
877
|
+
const change = new PendingChange('permissions', {
|
|
878
|
+
actor: Stack.actorFrom(opts),
|
|
879
|
+
associations: [{ op: 'remove', previous: matched }],
|
|
880
|
+
});
|
|
881
|
+
const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
|
|
882
|
+
this.announce(change, updated);
|
|
883
|
+
return updated;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Hold a permission set to the cross-element invariant — see
|
|
887
|
+
* validatePermissions() in access.ts for why it reads the post-state.
|
|
888
|
+
*/
|
|
889
|
+
assertPermissionSet(next) {
|
|
890
|
+
const errors = validatePermissions(next);
|
|
891
|
+
if (errors.length > 0)
|
|
892
|
+
throw new StackValidationError(errors);
|
|
893
|
+
}
|
|
894
|
+
/** The record, or the not-found refusal every mutating verb owes. */
|
|
895
|
+
async requireRecord(id) {
|
|
896
|
+
const record = await this.adapter.getRecord(id);
|
|
897
|
+
if (!record)
|
|
898
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
899
|
+
return record;
|
|
900
|
+
}
|
|
839
901
|
/**
|
|
840
902
|
* The checks a *caller-named* destination owes, before the cycle walk:
|
|
841
903
|
* `parentId` is a real, well-formed record id. Format first, so a
|
|
@@ -856,24 +918,18 @@ export class Stack {
|
|
|
856
918
|
}
|
|
857
919
|
/**
|
|
858
920
|
* Refuse an edge that would make a record its own ancestor — asked at
|
|
859
|
-
* every site that adds one
|
|
860
|
-
*
|
|
861
|
-
* downstream (a generator deriving a page path, a folder view) is
|
|
862
|
-
* written to survive a cycle.
|
|
921
|
+
* every site that adds one. Nothing downstream (a generator deriving a
|
|
922
|
+
* page path, a folder view) is written to survive a cycle.
|
|
863
923
|
*
|
|
864
|
-
* Walks with the unscoped adapter
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
* because a chain long enough to exhaust it is already pathological.
|
|
924
|
+
* Walks with the unscoped adapter: a walk that skipped the links a
|
|
925
|
+
* requester cannot read would let a cycle be assembled through them and
|
|
926
|
+
* break the invariant for every reader.
|
|
868
927
|
*
|
|
869
928
|
* Read-then-write, so two moves racing on opposite ends of one chain can
|
|
870
|
-
* both pass —
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
* carry a visited set rather than trust this alone; MAX_PARENT_DEPTH is
|
|
875
|
-
* the same posture applied here.
|
|
876
|
-
* See docs/spec/data-model.md § Reparenting.
|
|
929
|
+
* both pass — the same deferral as checkBindingUnique() above, since
|
|
930
|
+
* closing it would put a graph constraint in the storage contract.
|
|
931
|
+
* Consumers walking `parentId` should carry a visited set rather than
|
|
932
|
+
* trust this alone. See docs/spec/data-model.md § Reparenting.
|
|
877
933
|
*/
|
|
878
934
|
async assertNoParentCycle(id, parentId) {
|
|
879
935
|
let cursor = parentId;
|
|
@@ -903,8 +959,26 @@ export class Stack {
|
|
|
903
959
|
* destroys the only rows naming them, so a caller who means to erase the
|
|
904
960
|
* bytes too has nowhere else to read the argument from afterwards. See
|
|
905
961
|
* docs/spec/attachments.md § A purge strands the bytes it referenced.
|
|
962
|
+
*
|
|
963
|
+
* deleteAndReturn() is this plus the record itself; reach for that
|
|
964
|
+
* instead when the caller needs the record too — building a response
|
|
965
|
+
* body from a separate read beforehand is exactly the race and the extra
|
|
966
|
+
* round trip it exists to remove.
|
|
906
967
|
*/
|
|
907
968
|
async delete(id, opts = {}) {
|
|
969
|
+
const { referencedFileIds } = await this.deleteAndReturn(id, opts);
|
|
970
|
+
return { referencedFileIds };
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* delete(), reporting the record it acted on alongside the files it
|
|
974
|
+
* stranded: as it stood immediately before destruction for a hard
|
|
975
|
+
* delete, or as the resulting tombstone for a soft delete. Both are
|
|
976
|
+
* captured inside the same write that destroys or tombstones the record,
|
|
977
|
+
* never a read beforehand — a read-then-delete would leave a window
|
|
978
|
+
* where a concurrent write can land, get destroyed or overwritten by
|
|
979
|
+
* this call, and never appear in the record this returns.
|
|
980
|
+
*/
|
|
981
|
+
async deleteAndReturn(id, opts = {}) {
|
|
908
982
|
this.assertOpen();
|
|
909
983
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
910
984
|
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
@@ -921,9 +995,9 @@ export class Stack {
|
|
|
921
995
|
journal: change.journal,
|
|
922
996
|
});
|
|
923
997
|
if (!purged)
|
|
924
|
-
return { referencedFileIds: [] };
|
|
998
|
+
return { record: null, referencedFileIds: [] };
|
|
925
999
|
this.announce(change, purged);
|
|
926
|
-
return { referencedFileIds: await this.referencedFileIds(purged) };
|
|
1000
|
+
return { record: purged, referencedFileIds: await this.referencedFileIds(purged) };
|
|
927
1001
|
}
|
|
928
1002
|
const existing = await this.adapter.getRecord(id);
|
|
929
1003
|
if (!existing) {
|
|
@@ -933,7 +1007,7 @@ export class Stack {
|
|
|
933
1007
|
// A tombstone's references stand — undelete() must find its
|
|
934
1008
|
// attachments intact — so nothing is stranded and nothing is reported.
|
|
935
1009
|
if (existing.deletedAt)
|
|
936
|
-
return { referencedFileIds: [] };
|
|
1010
|
+
return { record: existing, referencedFileIds: [] };
|
|
937
1011
|
const change = new PendingChange('delete', { actor: Stack.actorFrom(opts) });
|
|
938
1012
|
const deleted = await this.adapter.deleteRecord(id, {
|
|
939
1013
|
...this.writeOptions(existing, opts),
|
|
@@ -941,7 +1015,7 @@ export class Stack {
|
|
|
941
1015
|
});
|
|
942
1016
|
if (deleted)
|
|
943
1017
|
this.announce(change, deleted);
|
|
944
|
-
return { referencedFileIds: [] };
|
|
1018
|
+
return { record: deleted ?? existing, referencedFileIds: [] };
|
|
945
1019
|
}
|
|
946
1020
|
/**
|
|
947
1021
|
* The files a record references, by the same definition
|
|
@@ -1035,18 +1109,12 @@ export class Stack {
|
|
|
1035
1109
|
return this.adapter.getVersions(id);
|
|
1036
1110
|
}
|
|
1037
1111
|
/**
|
|
1038
|
-
* A record's change journal, oldest first — what moved, who moved it,
|
|
1039
|
-
*
|
|
1040
|
-
* surface
|
|
1041
|
-
* changed what, gated on current read access, would make a record's past
|
|
1042
|
-
* exactly as reachable as its present. `ScopedStack` applies that gate;
|
|
1043
|
-
* this layer is unscoped and trusted by definition.
|
|
1112
|
+
* A record's change journal, oldest first — what moved, who moved it, and
|
|
1113
|
+
* the association deltas nothing else retains. `ScopedStack` applies the
|
|
1114
|
+
* mutate-surface gate; this layer is unscoped and trusted by definition.
|
|
1044
1115
|
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
* not the same answer, and a caller reconstructing an association's
|
|
1048
|
-
* history cannot tell them apart. A record that isn't there is
|
|
1049
|
-
* StackNotFoundError for the same reason, a purged one included.
|
|
1116
|
+
* A record that isn't there is StackNotFoundError, a purged one included:
|
|
1117
|
+
* a destroyed log and an empty one are not the same answer.
|
|
1050
1118
|
* See docs/spec/journal.md § Reading it.
|
|
1051
1119
|
*/
|
|
1052
1120
|
async getJournal(id, query = {}) {
|
|
@@ -1061,10 +1129,8 @@ export class Stack {
|
|
|
1061
1129
|
/**
|
|
1062
1130
|
* Restore a record to a previous version by creating a new version —
|
|
1063
1131
|
* never rewrites history. The snapshot is validated against its own
|
|
1064
|
-
* stored typeId
|
|
1065
|
-
*
|
|
1066
|
-
* containment), and restores neither associations nor permissions. See
|
|
1067
|
-
* docs/spec/versioning.md § Restore semantics.
|
|
1132
|
+
* stored typeId, and puts back `content` and `typeId` alone: a snapshot
|
|
1133
|
+
* carries nothing else. See docs/spec/versioning.md § Restore semantics.
|
|
1068
1134
|
*/
|
|
1069
1135
|
async restoreVersion(id, version, opts = {}) {
|
|
1070
1136
|
this.assertOpen();
|
|
@@ -1081,7 +1147,10 @@ export class Stack {
|
|
|
1081
1147
|
if (!type) {
|
|
1082
1148
|
throw new StackQueryError(`Unknown type: "${target.typeId}"`);
|
|
1083
1149
|
}
|
|
1084
|
-
const errors =
|
|
1150
|
+
const errors = [
|
|
1151
|
+
...validateContent(target.content, type.schema),
|
|
1152
|
+
...validateGrantee(target.typeId, target.content),
|
|
1153
|
+
];
|
|
1085
1154
|
if (errors.length > 0) {
|
|
1086
1155
|
throw new StackValidationError(errors);
|
|
1087
1156
|
}
|
|
@@ -1089,36 +1158,17 @@ export class Stack {
|
|
|
1089
1158
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
|
|
1090
1159
|
}
|
|
1091
1160
|
// Restoring is a write like any other, so it owes the same immutability
|
|
1092
|
-
// check a content patch pays
|
|
1093
|
-
// would otherwise move the binding by rolling content back.
|
|
1094
|
-
// needs
|
|
1095
|
-
//
|
|
1161
|
+
// check a content patch pays: a snapshot taken before a card claimed
|
|
1162
|
+
// its DID would otherwise move the binding by rolling content back.
|
|
1163
|
+
// Uniqueness needs none — a restore can only put back a value this same
|
|
1164
|
+
// card already held.
|
|
1096
1165
|
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
1097
1166
|
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
1098
1167
|
}
|
|
1099
|
-
// A restore
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
|
|
1103
|
-
// naming a destination, it is history being put back, and the container
|
|
1104
|
-
// may have been hard-deleted since. Refusing here would make an
|
|
1105
|
-
// unrelated deletion cost the record its content rollback, which is the
|
|
1106
|
-
// recoverability this whole verb exists for. A dangling parentId is
|
|
1107
|
-
// legal at rest anyway — deleting a container never touches its
|
|
1108
|
-
// children. See docs/spec/versioning.md § Restore semantics.
|
|
1109
|
-
const previousParentId = existing.parentId ?? null;
|
|
1110
|
-
const targetParentId = target.parentId ?? null;
|
|
1111
|
-
const moves = targetParentId !== previousParentId;
|
|
1112
|
-
if (moves && targetParentId !== null) {
|
|
1113
|
-
await this.assertNoParentCycle(id, targetParentId);
|
|
1114
|
-
}
|
|
1115
|
-
// Associations never restore, for any Record — a snapshot never
|
|
1116
|
-
// carries them, so there's nothing here for the adapter to roll back.
|
|
1117
|
-
// See docs/spec/versioning.md § Restore semantics.
|
|
1118
|
-
const change = new PendingChange('restore', {
|
|
1119
|
-
actor: Stack.actorFrom(opts),
|
|
1120
|
-
...(moves && { previousParentId }),
|
|
1121
|
-
});
|
|
1168
|
+
// A restore adds no containment edge and takes none away, so there is
|
|
1169
|
+
// no cycle for it to close and nothing for the destination checks to
|
|
1170
|
+
// gate. See docs/spec/versioning.md § Restore semantics.
|
|
1171
|
+
const change = new PendingChange('restore', { actor: Stack.actorFrom(opts) });
|
|
1122
1172
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1123
1173
|
...this.writeOptions(existing, opts),
|
|
1124
1174
|
journal: change.journal,
|
|
@@ -1127,23 +1177,15 @@ export class Stack {
|
|
|
1127
1177
|
return restored;
|
|
1128
1178
|
}
|
|
1129
1179
|
/**
|
|
1130
|
-
* Commit a per-record migration
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
* (computed client-side by the type's owning app, per
|
|
1135
|
-
* docs/spec/wire-format.md § Migration commit) rather than a registered
|
|
1136
|
-
* Migration function. Snapshots the prior state to version history, same
|
|
1137
|
-
* as mutate()/restoreVersion(), and takes the same optional `ifVersion`
|
|
1138
|
-
* precondition every version-bumping mutation takes — checked atomically
|
|
1139
|
-
* at the adapter, not here (see docs/spec/versioning.md § Optimistic
|
|
1140
|
-
* concurrency).
|
|
1180
|
+
* Commit a per-record migration — the single-record counterpart to
|
|
1181
|
+
* migrateAll(), with `content` supplied by the caller rather than by a
|
|
1182
|
+
* registered Migration function. See docs/spec/wire-format.md § Migration
|
|
1183
|
+
* commit.
|
|
1141
1184
|
*
|
|
1142
1185
|
* Because `content` is a full replacement written under a new `typeId`,
|
|
1143
1186
|
* this is create-shaped at the destination *and* update-shaped over the
|
|
1144
|
-
* record as it stands, so it owes both sets of integrity checks
|
|
1145
|
-
*
|
|
1146
|
-
* half would make migrate a second, unguarded write path to the same
|
|
1187
|
+
* record as it stands, so it owes both sets of integrity checks. Missing
|
|
1188
|
+
* either half would make migrate a second, unguarded write path to a
|
|
1147
1189
|
* state create()/mutate() refuse to reach.
|
|
1148
1190
|
*/
|
|
1149
1191
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
@@ -1180,6 +1222,7 @@ export class Stack {
|
|
|
1180
1222
|
...validateReservedKeys(content),
|
|
1181
1223
|
...validateContentKeys(content),
|
|
1182
1224
|
...validateContent(content, type.schema),
|
|
1225
|
+
...validateGrantee(toTypeId, content),
|
|
1183
1226
|
];
|
|
1184
1227
|
if (errors.length > 0) {
|
|
1185
1228
|
throw new StackValidationError(errors);
|
|
@@ -1248,18 +1291,14 @@ export class Stack {
|
|
|
1248
1291
|
}
|
|
1249
1292
|
}
|
|
1250
1293
|
/**
|
|
1251
|
-
* Bindings across a migration. `content` is a full replacement
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
* families' binding fields, so a card can neither shed its DID by
|
|
1257
|
-
* migrating out of `_entity`/`_app` nor pick one up on the way in.
|
|
1294
|
+
* Bindings across a migration. `content` is a full replacement, so every
|
|
1295
|
+
* binding field either keeps its value, moves to a new one, or is shed by
|
|
1296
|
+
* omission — and immutability refuses the last two. Asked across the
|
|
1297
|
+
* union of both families' binding fields, so a card can neither shed its
|
|
1298
|
+
* DID by migrating out of `_entity`/`_app` nor pick one up on the way in.
|
|
1258
1299
|
*
|
|
1259
|
-
* Uniqueness is asked only of the destination family,
|
|
1260
|
-
* record
|
|
1261
|
-
* itself — re-sending the value it already holds claims nothing.
|
|
1262
|
-
* See docs/spec/identity.md § DID bindings.
|
|
1300
|
+
* Uniqueness is asked only of the destination family, excluding the
|
|
1301
|
+
* record itself. See docs/spec/identity.md § DID bindings.
|
|
1263
1302
|
*/
|
|
1264
1303
|
async checkBindingsOnMigrate(fromTypeId, toTypeId, id, existing, content) {
|
|
1265
1304
|
const fromFamily = baseIdOf(fromTypeId);
|
|
@@ -1283,16 +1322,10 @@ export class Stack {
|
|
|
1283
1322
|
* claiming one value would leave that lookup without a single answer, and
|
|
1284
1323
|
* ambiguity is all an impersonating card needs. Enforced here rather than
|
|
1285
1324
|
* by schema, since uniqueness is a property of the set, not of the value.
|
|
1286
|
-
* Called by the paths that can introduce a binding: create and update.
|
|
1287
1325
|
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
* Read-then-write, so two creates racing on one value can both pass.
|
|
1293
|
-
* Closing that properly means a unique index over a JSON field, which
|
|
1294
|
-
* each adapter would enforce separately — a decision about where
|
|
1295
|
-
* uniqueness lives, not a local fix.
|
|
1326
|
+
* Read-then-write, so two creates racing on one value can both pass:
|
|
1327
|
+
* closing that means a unique index over a JSON field, which is a
|
|
1328
|
+
* decision about where uniqueness lives rather than a local fix.
|
|
1296
1329
|
* See docs/spec/identity.md § DID bindings.
|
|
1297
1330
|
*/
|
|
1298
1331
|
async checkBindingUnique(family, field, value, excludeId) {
|
|
@@ -1747,9 +1780,10 @@ export class Stack {
|
|
|
1747
1780
|
// -------------------------------------------------------
|
|
1748
1781
|
/**
|
|
1749
1782
|
* Create _grant records authorizing entities to act on records of
|
|
1750
|
-
* specific types
|
|
1751
|
-
*
|
|
1752
|
-
*
|
|
1783
|
+
* specific types. The target is the grantee the record carries:
|
|
1784
|
+
* `{ kind: 'entity' }` for one DID, `{ kind: 'group' }` for a `_group`
|
|
1785
|
+
* Record's roster at a role, `{ kind: 'authenticated' }` for any
|
|
1786
|
+
* authenticated entity.
|
|
1753
1787
|
*
|
|
1754
1788
|
* Granting an **app** a `-own` action does not contain it the way the
|
|
1755
1789
|
* suffix suggests: when that app acts for someone, `-own` is read as the
|
|
@@ -1759,9 +1793,8 @@ export class Stack {
|
|
|
1759
1793
|
* needs, not the suffix that looks narrowest. See
|
|
1760
1794
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1761
1795
|
*
|
|
1762
|
-
* The grantee lives in content.
|
|
1763
|
-
*
|
|
1764
|
-
* grants.
|
|
1796
|
+
* The grantee lives in content.grantee, not record.entityId. See
|
|
1797
|
+
* docs/spec/access-control.md § Type-level grants.
|
|
1765
1798
|
*/
|
|
1766
1799
|
async grant(target, grants) {
|
|
1767
1800
|
this.assertOpen();
|
|
@@ -1772,37 +1805,43 @@ export class Stack {
|
|
|
1772
1805
|
records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1773
1806
|
typeId: g.typeId,
|
|
1774
1807
|
actions: g.actions,
|
|
1775
|
-
|
|
1776
|
-
...(target !== null && typeof target === 'object' && { granteeGroupId: target.groupId }),
|
|
1808
|
+
grantee: target,
|
|
1777
1809
|
}));
|
|
1778
1810
|
}
|
|
1779
1811
|
return records;
|
|
1780
1812
|
}
|
|
1781
1813
|
/**
|
|
1782
|
-
* List _grant records. Omit `
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1814
|
+
* List _grant records. Omit `query` for all grants;
|
|
1815
|
+
* `{ kind: 'authenticated' }` for only default grants;
|
|
1816
|
+
* `{ kind: 'group' }` for grants naming that exact group and role, or
|
|
1817
|
+
* `role: 'any'` for every grant naming the group;
|
|
1818
|
+
* `{ kind: 'entity' }` for the grants that currently apply to that entity
|
|
1819
|
+
* (ones naming them, ones naming a group they belong to at a role they
|
|
1820
|
+
* hold, plus every default grant) — the same resolution hasGrant() uses.
|
|
1821
|
+
*
|
|
1822
|
+
* Every arm but `entity` answers identity — what `grant()` would have
|
|
1823
|
+
* written with the same argument. The `entity` arm answers coverage, so
|
|
1824
|
+
* its result is not a preview of what `revoke()` would withdraw.
|
|
1825
|
+
* See docs/spec/access-control.md § Type-level grants.
|
|
1787
1826
|
*/
|
|
1788
|
-
async listGrants(
|
|
1827
|
+
async listGrants(query) {
|
|
1789
1828
|
this.assertOpen();
|
|
1790
|
-
if (
|
|
1791
|
-
validateGrantTarget(
|
|
1829
|
+
if (query !== undefined)
|
|
1830
|
+
validateGrantTarget(query, true);
|
|
1792
1831
|
const all = await loadGrantRecords((q) => this.query(q));
|
|
1793
|
-
if (
|
|
1832
|
+
if (query === undefined)
|
|
1794
1833
|
return all;
|
|
1795
|
-
if (
|
|
1796
|
-
return all.filter((r) => matchesGrantTarget(r.content,
|
|
1834
|
+
if (query.kind !== 'entity') {
|
|
1835
|
+
return all.filter((r) => matchesGrantTarget(r.content, query));
|
|
1797
1836
|
}
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1837
|
+
// An entity query resolves group rosters, since a grant naming a group
|
|
1838
|
+
// the entity belongs to also currently applies to them. Shares
|
|
1800
1839
|
// grantCoversGrantee() with the access checks, so a listing can't
|
|
1801
1840
|
// disagree with them about who a grant covers.
|
|
1802
1841
|
const groupRoles = new Map();
|
|
1803
1842
|
const result = [];
|
|
1804
1843
|
for (const r of all) {
|
|
1805
|
-
const covers = await grantCoversGrantee(r.content,
|
|
1844
|
+
const covers = await grantCoversGrantee(r.content, query.entityId, {
|
|
1806
1845
|
allowDefault: true,
|
|
1807
1846
|
allowGroup: true,
|
|
1808
1847
|
groupRoles,
|
|
@@ -1815,20 +1854,32 @@ export class Stack {
|
|
|
1815
1854
|
}
|
|
1816
1855
|
/**
|
|
1817
1856
|
* The inverse of grant(): soft-deletes _grant records matching `target`
|
|
1818
|
-
*
|
|
1819
|
-
*
|
|
1820
|
-
* owner can undelete a revocation.
|
|
1857
|
+
* and each `{ typeId, actions }` pair, at the same granularity grant()
|
|
1858
|
+
* writes — the grantee is matched whole, role included. A soft delete
|
|
1859
|
+
* like any other — the owner can undelete a revocation.
|
|
1860
|
+
*
|
|
1861
|
+
* Returns the grants it withdrew, as they stood, so a revocation that
|
|
1862
|
+
* matched nothing says so rather than passing in silence. An empty result
|
|
1863
|
+
* is not an error — a grant already withdrawn or never written is the
|
|
1864
|
+
* ordinary case, and re-running a revocation has to stay safe.
|
|
1865
|
+
* See docs/spec/access-control.md § Type-level grants.
|
|
1821
1866
|
*/
|
|
1822
1867
|
async revoke(target, grants) {
|
|
1823
1868
|
this.assertOpen();
|
|
1824
1869
|
validateGrantTarget(target);
|
|
1870
|
+
const revoked = [];
|
|
1825
1871
|
const all = await loadGrantRecords((q) => this.query(q));
|
|
1826
1872
|
for (const g of grants) {
|
|
1827
1873
|
const familyId = baseIdOf(g.typeId);
|
|
1828
1874
|
const actionSet = new Set(g.actions);
|
|
1829
1875
|
const matches = all.filter((r) => {
|
|
1830
1876
|
const c = r.content;
|
|
1831
|
-
|
|
1877
|
+
// Establishes the family and that `actions` is a list, so the exact
|
|
1878
|
+
// match below reads a real one. Matched against the stored list
|
|
1879
|
+
// rather than the reach, so a grant carrying an action this
|
|
1880
|
+
// vocabulary drops is not withdrawn by a target that omits it.
|
|
1881
|
+
const reach = grantReach(c);
|
|
1882
|
+
if (!reach || reach.familyId !== familyId)
|
|
1832
1883
|
return false;
|
|
1833
1884
|
if (!matchesGrantTarget(c, target))
|
|
1834
1885
|
return false;
|
|
@@ -1836,8 +1887,10 @@ export class Stack {
|
|
|
1836
1887
|
});
|
|
1837
1888
|
for (const match of matches) {
|
|
1838
1889
|
await this.delete(match.id);
|
|
1890
|
+
revoked.push(match);
|
|
1839
1891
|
}
|
|
1840
1892
|
}
|
|
1893
|
+
return revoked;
|
|
1841
1894
|
}
|
|
1842
1895
|
// -------------------------------------------------------
|
|
1843
1896
|
// Private helpers
|
|
@@ -1915,8 +1968,22 @@ export class Stack {
|
|
|
1915
1968
|
await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', {
|
|
1916
1969
|
typeId: { kind: 'string', required: true },
|
|
1917
1970
|
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
1918
|
-
|
|
1919
|
-
|
|
1971
|
+
// Required, and closed: a Grant's reach is spelled by its `grantee`,
|
|
1972
|
+
// so a record arriving without one is refused here rather than read
|
|
1973
|
+
// as a grant to every authenticated entity. The arms differ in which
|
|
1974
|
+
// fields they carry, which a schema cannot express — evaluation reads
|
|
1975
|
+
// the `kind` and confers nothing on one it does not recognize.
|
|
1976
|
+
// See docs/spec/access-control.md § Type-level grants.
|
|
1977
|
+
grantee: {
|
|
1978
|
+
kind: 'object',
|
|
1979
|
+
required: true,
|
|
1980
|
+
properties: {
|
|
1981
|
+
kind: { kind: 'string', required: true },
|
|
1982
|
+
entityId: { kind: 'string' },
|
|
1983
|
+
groupId: { kind: 'string' },
|
|
1984
|
+
role: { kind: 'string' },
|
|
1985
|
+
},
|
|
1986
|
+
},
|
|
1920
1987
|
});
|
|
1921
1988
|
await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
|
|
1922
1989
|
// Deliberately `string`, not `file-ref`: attachmentFileId matching
|
|
@@ -1960,13 +2027,12 @@ export class Stack {
|
|
|
1960
2027
|
}
|
|
1961
2028
|
/**
|
|
1962
2029
|
* Snapshot of a record's prior state, passed with the mutating adapter
|
|
1963
|
-
* call so snapshot and mutation land in one atomic write.
|
|
1964
|
-
*
|
|
1965
|
-
*
|
|
1966
|
-
*
|
|
1967
|
-
*
|
|
1968
|
-
*
|
|
1969
|
-
* docs/spec/versioning.md § Version history.
|
|
2030
|
+
* call so snapshot and mutation land in one atomic write. Carries what
|
|
2031
|
+
* only a snapshot preserves: `content` and the `typeId` it is read
|
|
2032
|
+
* under. Containment, listing and associations — the authority ones
|
|
2033
|
+
* among them — are all kept by the journal instead, so no version ever
|
|
2034
|
+
* snapshots them and there is nothing for a restore to roll them back
|
|
2035
|
+
* to. See docs/spec/versioning.md § Version history.
|
|
1970
2036
|
*/
|
|
1971
2037
|
buildVersionSnapshot(record) {
|
|
1972
2038
|
return {
|
|
@@ -1977,8 +2043,6 @@ export class Stack {
|
|
|
1977
2043
|
...(record.entityId && { entityId: record.entityId }),
|
|
1978
2044
|
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
1979
2045
|
...(record.updatedVia && { updatedVia: record.updatedVia }),
|
|
1980
|
-
...(record.parentId !== undefined && { parentId: record.parentId }),
|
|
1981
|
-
...(record.permissions && { permissions: record.permissions }),
|
|
1982
2046
|
};
|
|
1983
2047
|
}
|
|
1984
2048
|
}
|