@haverstack/core 0.39.0 → 0.41.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 +79 -16
- package/dist/access.js +1 -1
- package/dist/access.js.map +1 -1
- package/dist/adapter-entry.d.ts +2 -1
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +2 -1
- package/dist/adapter-entry.js.map +1 -1
- package/dist/changes.js +3 -3
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +2 -3
- package/dist/combine.js.map +1 -1
- package/dist/errors.d.ts +16 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +23 -2
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +9 -1
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +34 -4
- package/dist/grants.js.map +1 -1
- package/dist/identity-bindings.d.ts.map +1 -1
- package/dist/identity-bindings.js +3 -0
- package/dist/identity-bindings.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +144 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +223 -0
- package/dist/install.js.map +1 -0
- package/dist/query-validation.d.ts +34 -1
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +101 -1
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +15 -1
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +33 -3
- package/dist/record-changes.js.map +1 -1
- package/dist/record-id.js +1 -1
- package/dist/record-id.js.map +1 -1
- package/dist/schema.d.ts +6 -7
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +33 -10
- package/dist/schema.js.map +1 -1
- package/dist/scoped-stack.d.ts +50 -23
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +202 -129
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack.d.ts +182 -45
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +481 -192
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +28 -11
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +42 -37
- package/dist/testing.js.map +1 -1
- package/dist/type-handle.d.ts +157 -0
- package/dist/type-handle.d.ts.map +1 -0
- package/dist/type-handle.js +131 -0
- package/dist/type-handle.js.map +1 -0
- package/dist/types.d.ts +91 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +7 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +131 -7
- package/dist/validate.js.map +1 -1
- package/dist/wire-body.d.ts +19 -3
- package/dist/wire-body.d.ts.map +1 -1
- package/dist/wire-body.js +99 -2
- package/dist/wire-body.js.map +1 -1
- package/dist/wire-entry.d.ts +2 -2
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +4 -4
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-request.d.ts +18 -1
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +33 -1
- package/dist/wire-request.js.map +1 -1
- package/package.json +1 -1
package/dist/scoped-stack.js
CHANGED
|
@@ -22,14 +22,19 @@ import { validatePatchValues } from './validate.js';
|
|
|
22
22
|
import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone } from './access.js';
|
|
23
23
|
import { ChangeEmitter, Subscription, matchesFilter, passesUnlistedBoundary, assertSinceUsable, } from './changes.js';
|
|
24
24
|
import { SYSTEM_TYPES } from './types.js';
|
|
25
|
-
import {
|
|
26
|
-
import { assertAuthorityAssociations, assertDataAssociations, assertSortCapability, assertValidAssociationFilters, assertValidSort, filtersContent, } from './query-validation.js';
|
|
25
|
+
import { StackError, StackConflictError, StackNotFoundError, StackPermissionError, RelayScopeError, StackValidationError, } from './errors.js';
|
|
26
|
+
import { assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidSort, filtersContent, } from './query-validation.js';
|
|
27
27
|
import { grantConveys, grantCoversGrantee, grantReach, loadGrantRecords, UNGRANTABLE_SYSTEM_TYPES, } from './grants.js';
|
|
28
28
|
import { bindingFieldsOf } from './identity-bindings.js';
|
|
29
|
+
import { claimedFamilies, familyStanding, linkedIds, INSTALL_APP_LABEL } from './install.js';
|
|
29
30
|
import { assertAttachmentSize } from './limits.js';
|
|
30
31
|
import { validateIdTimestampSkew, validateRecordId } from './record-id.js';
|
|
31
32
|
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, findFirstMatch, lookupEntityByDid, queryAllPages, } from './stack-reads.js';
|
|
32
33
|
import { associationDelta, associationEqual, isGroupRecord, presentDeleted, withoutAuthorityChanges, assertNonEmptyChangeSet, } from './record-changes.js';
|
|
34
|
+
// Every import from stack.js is type-only: a ScopedStack never constructs
|
|
35
|
+
// a Stack, so nothing here closes a runtime cycle with stack.ts, which does
|
|
36
|
+
// construct a ScopedStack.
|
|
37
|
+
import { isTypeHandle, typedCreate, typedGet, typedMutate, typedQuery, typedSubscribe, } from './type-handle.js';
|
|
33
38
|
/**
|
|
34
39
|
* The authority lookups canRead needs, held for the life of one
|
|
35
40
|
* subscription. A subscription is long-lived where a query is not, so the
|
|
@@ -196,7 +201,7 @@ export class ScopedStack {
|
|
|
196
201
|
get capabilities() {
|
|
197
202
|
return this.stack.capabilities;
|
|
198
203
|
}
|
|
199
|
-
resolveRecord = (id) => this.stack.get(id);
|
|
204
|
+
resolveRecord = (id) => this.stack.get(id, { includeDeleted: true });
|
|
200
205
|
/** Every `_grant` Record — see loadGrantRecords(). */
|
|
201
206
|
loadGrants = () => loadGrantRecords((q) => this.stack.query(q));
|
|
202
207
|
/** Whether a delegated app is acting for someone other than itself. */
|
|
@@ -286,9 +291,8 @@ export class ScopedStack {
|
|
|
286
291
|
grantRecords = prefetchedGrants;
|
|
287
292
|
}
|
|
288
293
|
else {
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// versions. Cursor-walked to see grants past page one.
|
|
294
|
+
// Cursor-walked to see grants past page one. A stored baseId is
|
|
295
|
+
// judged by grantReach(), which refuses a versioned one.
|
|
292
296
|
grantRecords = await this.loadGrants();
|
|
293
297
|
}
|
|
294
298
|
for (const r of grantRecords) {
|
|
@@ -424,13 +428,11 @@ export class ScopedStack {
|
|
|
424
428
|
* the one way through it a `_grant` Record stays open to.
|
|
425
429
|
*/
|
|
426
430
|
async requireUpdatable(id, opts = {}) {
|
|
427
|
-
const mutating = opts.mutating ?? true;
|
|
428
431
|
// The `_grant` write fence applies to mutating callers only: reading a
|
|
429
432
|
// grant Record's history is not the escalation that fence exists to stop.
|
|
430
|
-
|
|
431
|
-
fenceGrantRecord: mutating,
|
|
433
|
+
return this.requireVerb(id, ['update-own', 'update-any'], {
|
|
434
|
+
fenceGrantRecord: opts.mutating ?? true,
|
|
432
435
|
});
|
|
433
|
-
return this.refuseIfDeleted(record, mutating);
|
|
434
436
|
}
|
|
435
437
|
/**
|
|
436
438
|
* The write gate both requireUpdatable() and requireDeletable() are: the
|
|
@@ -440,33 +442,26 @@ export class ScopedStack {
|
|
|
440
442
|
* See docs/spec/access-control.md § Delegation: principal and subject.
|
|
441
443
|
*/
|
|
442
444
|
async requireVerb(id, actions, opts) {
|
|
443
|
-
const record = await this.stack.get(id);
|
|
445
|
+
const record = await this.stack.get(id, { includeDeleted: true });
|
|
444
446
|
if (!record)
|
|
445
447
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
446
448
|
if (opts.fenceGrantRecord)
|
|
447
449
|
await this.requireOwnerForGrantRecord(record);
|
|
448
450
|
if (isGroupRecord(record)) {
|
|
449
|
-
if (!this.isGroupManager(record))
|
|
450
|
-
throw await this.denialFor(record);
|
|
451
|
+
if (!this.isGroupManager(record)) {
|
|
452
|
+
throw await this.denialFor(record, `Cannot change group "${id}": only its admins can manage it`);
|
|
453
|
+
}
|
|
451
454
|
return record;
|
|
452
455
|
}
|
|
453
|
-
const
|
|
454
|
-
(await this.subjectAllows(record.typeId, actions, { record }))
|
|
455
|
-
|
|
456
|
-
if (!
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
* mutating caller, so the history readers borrowing this gate still work,
|
|
463
|
-
* and only after the authority decision, so "exists but deleted" is never
|
|
464
|
-
* a probe a stranger can run. See docs/spec/versioning.md § Mutations are
|
|
465
|
-
* refused, not applied to a tombstone.
|
|
466
|
-
*/
|
|
467
|
-
refuseIfDeleted(record, mutating) {
|
|
468
|
-
if (mutating && record.deletedAt) {
|
|
469
|
-
throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
|
|
456
|
+
const subjectReaches = (await this.checkWrite(record)) ||
|
|
457
|
+
(await this.subjectAllows(record.typeId, actions, { record }));
|
|
458
|
+
const principalHolds = await this.principalAllows(record.typeId, actions);
|
|
459
|
+
if (!subjectReaches || !principalHolds) {
|
|
460
|
+
const verb = actions[0].split('-')[0];
|
|
461
|
+
const what = `Cannot ${verb} "${id}" (${record.typeId})`;
|
|
462
|
+
throw await this.denialFor(record, subjectReaches
|
|
463
|
+
? `${what}: the app acting for this entity holds no ${verb} grant`
|
|
464
|
+
: `${what}: requires ${actions.join(' or ')}`);
|
|
470
465
|
}
|
|
471
466
|
return record;
|
|
472
467
|
}
|
|
@@ -492,7 +487,7 @@ export class ScopedStack {
|
|
|
492
487
|
async canReadReferent(recordId) {
|
|
493
488
|
if (this.ownerActingAlone)
|
|
494
489
|
return true;
|
|
495
|
-
const record = await this.stack.get(recordId);
|
|
490
|
+
const record = await this.stack.get(recordId, { includeDeleted: true });
|
|
496
491
|
if (!record)
|
|
497
492
|
return false;
|
|
498
493
|
return this.canRead(record);
|
|
@@ -582,8 +577,9 @@ export class ScopedStack {
|
|
|
582
577
|
const value = content[field];
|
|
583
578
|
if (typeof value !== 'string')
|
|
584
579
|
continue;
|
|
585
|
-
if (!(await this.canAccessFile(value)))
|
|
586
|
-
throw new StackPermissionError();
|
|
580
|
+
if (!(await this.canAccessFile(value))) {
|
|
581
|
+
throw new StackPermissionError(`Cannot reference file "${value}" in field "${field}": it does not exist or you cannot read it`);
|
|
582
|
+
}
|
|
587
583
|
}
|
|
588
584
|
}
|
|
589
585
|
/**
|
|
@@ -600,8 +596,9 @@ export class ScopedStack {
|
|
|
600
596
|
*/
|
|
601
597
|
async requireAssociationAccess(typeId, association) {
|
|
602
598
|
if (association.kind === 'attachment') {
|
|
603
|
-
if (!(await this.canAccessFile(association.fileId)))
|
|
604
|
-
throw new StackPermissionError();
|
|
599
|
+
if (!(await this.canAccessFile(association.fileId))) {
|
|
600
|
+
throw new StackPermissionError(`Cannot reference file "${association.fileId}": it does not exist or you cannot read it`);
|
|
601
|
+
}
|
|
605
602
|
}
|
|
606
603
|
else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
|
|
607
604
|
const { target } = association;
|
|
@@ -611,22 +608,16 @@ export class ScopedStack {
|
|
|
611
608
|
// leave one spelling of a local Record ungated.
|
|
612
609
|
if (target.kind !== 'record' || target.stackUrl)
|
|
613
610
|
return;
|
|
614
|
-
if (!(await this.canReadReferent(target.recordId)))
|
|
615
|
-
throw new StackPermissionError();
|
|
611
|
+
if (!(await this.canReadReferent(target.recordId))) {
|
|
612
|
+
throw new StackPermissionError(`Cannot reference record "${target.recordId}": it does not exist or you cannot read it`);
|
|
613
|
+
}
|
|
616
614
|
}
|
|
617
615
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
* authorship — an absent `createdBy` means an unscoped `Stack` wrote it.
|
|
624
|
-
* `createdAt`/`updatedAt` are refused to everyone but the owner acting
|
|
625
|
-
* alone, rather than dropped, so an app never believes it published
|
|
626
|
-
* something it didn't. See docs/spec/access-control.md and
|
|
627
|
-
* docs/spec/data-model.md § Record IDs.
|
|
628
|
-
*/
|
|
629
|
-
async create(typeId, content, opts = {}) {
|
|
616
|
+
async create(typeIdOrHandle, content, opts = {}) {
|
|
617
|
+
if (isTypeHandle(typeIdOrHandle)) {
|
|
618
|
+
return typedCreate(this, typeIdOrHandle, content, opts);
|
|
619
|
+
}
|
|
620
|
+
const typeId = typeIdOrHandle;
|
|
630
621
|
const principal = this.#principalId;
|
|
631
622
|
if (!principal)
|
|
632
623
|
throw new StackPermissionError('Anonymous requesters cannot create records');
|
|
@@ -648,7 +639,7 @@ export class ScopedStack {
|
|
|
648
639
|
if (!this.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
|
|
649
640
|
const fileId = content.fileId;
|
|
650
641
|
if (typeof fileId !== 'string' || !(await this.hasReadableReference(fileId))) {
|
|
651
|
-
throw new StackPermissionError();
|
|
642
|
+
throw new StackPermissionError(`Cannot reference file "${String(fileId)}": it does not exist or you cannot read it`);
|
|
652
643
|
}
|
|
653
644
|
}
|
|
654
645
|
if (opts.permissions?.length && !this.mayGrantAccess()) {
|
|
@@ -672,7 +663,7 @@ export class ScopedStack {
|
|
|
672
663
|
}
|
|
673
664
|
}
|
|
674
665
|
if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
|
|
675
|
-
throw new StackPermissionError();
|
|
666
|
+
throw new StackPermissionError(`Cannot reference record "${opts.parentId}" as parent: it does not exist or you cannot read it`);
|
|
676
667
|
}
|
|
677
668
|
for (const assoc of opts.associations ?? []) {
|
|
678
669
|
await this.requireAssociationAccess(typeId, assoc);
|
|
@@ -708,13 +699,11 @@ export class ScopedStack {
|
|
|
708
699
|
return false;
|
|
709
700
|
return entityId === this.stack.ownerEntityId || entityId === record.createdBy?.subjectId;
|
|
710
701
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
*/
|
|
717
|
-
async get(id, opts = {}) {
|
|
702
|
+
async get(idOrHandle, idOrOpts = {}) {
|
|
703
|
+
if (isTypeHandle(idOrHandle))
|
|
704
|
+
return typedGet(this, idOrHandle, idOrOpts);
|
|
705
|
+
const id = idOrHandle;
|
|
706
|
+
const opts = idOrOpts;
|
|
718
707
|
const record = await this.stack.get(id, opts);
|
|
719
708
|
if (!record)
|
|
720
709
|
return null;
|
|
@@ -734,16 +723,14 @@ export class ScopedStack {
|
|
|
734
723
|
async getOwnerEntity() {
|
|
735
724
|
return this.getEntityByDid(this.stack.ownerEntityId);
|
|
736
725
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
* exhaustion.
|
|
742
|
-
*/
|
|
743
|
-
async query(query = {}) {
|
|
726
|
+
async query(first = {}, typedQueryArg) {
|
|
727
|
+
if (isTypeHandle(first))
|
|
728
|
+
return typedQuery(this, first, typedQueryArg);
|
|
729
|
+
const query = first;
|
|
744
730
|
assertValidSort(query.sort);
|
|
745
731
|
assertSortCapability(query.sort, this.stack.capabilities);
|
|
746
732
|
assertValidAssociationFilters(query.filter);
|
|
733
|
+
assertValidBaseIdFilter(query.filter);
|
|
747
734
|
if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
|
|
748
735
|
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
749
736
|
}
|
|
@@ -771,17 +758,13 @@ export class ScopedStack {
|
|
|
771
758
|
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
772
759
|
return { records, cursor: page.cursor };
|
|
773
760
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
* applied and no key is silently dropped.
|
|
782
|
-
* See docs/spec/access-control.md § Composing a change set.
|
|
783
|
-
*/
|
|
784
|
-
async mutate(id, changes, opts = {}) {
|
|
761
|
+
async mutate(first, second, third, fourth = {}) {
|
|
762
|
+
if (isTypeHandle(first)) {
|
|
763
|
+
return typedMutate(this, first, second, third, fourth);
|
|
764
|
+
}
|
|
765
|
+
const id = first;
|
|
766
|
+
const changes = second;
|
|
767
|
+
const opts = (third ?? {});
|
|
785
768
|
// Ahead of every gate: a malformed change set is a validation error
|
|
786
769
|
// for every requester, rather than one for the owner and a permission
|
|
787
770
|
// refusal for everyone else.
|
|
@@ -856,12 +839,15 @@ export class ScopedStack {
|
|
|
856
839
|
await this.requireAssociationAccess(record.typeId, assoc);
|
|
857
840
|
}
|
|
858
841
|
if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
|
|
859
|
-
throw new StackPermissionError();
|
|
842
|
+
throw new StackPermissionError(`Cannot reference record "${changes.parentId}" as parent: it does not exist or you cannot read it`);
|
|
860
843
|
}
|
|
861
844
|
return this.stack.mutate(id, authorized, { ...opts, ...this.actor });
|
|
862
845
|
}
|
|
863
|
-
async patchContent(
|
|
864
|
-
|
|
846
|
+
async patchContent(first, second, third, fourth) {
|
|
847
|
+
if (isTypeHandle(first)) {
|
|
848
|
+
return this.mutate(first, second, { contentPatch: third }, fourth);
|
|
849
|
+
}
|
|
850
|
+
return this.mutate(first, { contentPatch: second }, third);
|
|
865
851
|
}
|
|
866
852
|
/**
|
|
867
853
|
* The record, having established that this request may decide who else
|
|
@@ -871,15 +857,12 @@ export class ScopedStack {
|
|
|
871
857
|
* deliberately does not confer.
|
|
872
858
|
*/
|
|
873
859
|
async requireReshareable(id) {
|
|
874
|
-
const record = await this.stack.get(id);
|
|
860
|
+
const record = await this.stack.get(id, { includeDeleted: true });
|
|
875
861
|
if (!record)
|
|
876
862
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
877
863
|
await this.requireOwnerForGrantRecord(record);
|
|
878
864
|
await this.requireReshareOf(record);
|
|
879
|
-
|
|
880
|
-
// soft-delete refusal has to be asked here too — after the authority
|
|
881
|
-
// decision above, for the reason refuseIfDeleted() gives.
|
|
882
|
-
return this.refuseIfDeleted(record, true);
|
|
865
|
+
return record;
|
|
883
866
|
}
|
|
884
867
|
/**
|
|
885
868
|
* Whether a change set's `permissions` key actually moves the ACL. Read
|
|
@@ -896,8 +879,9 @@ export class ScopedStack {
|
|
|
896
879
|
}
|
|
897
880
|
/** The reshare decision alone, for a record already read and write-gated. */
|
|
898
881
|
async requireReshareOf(record) {
|
|
899
|
-
if (!this.canReshare(record))
|
|
900
|
-
throw await this.denialFor(record);
|
|
882
|
+
if (!this.canReshare(record)) {
|
|
883
|
+
throw await this.denialFor(record, `Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`);
|
|
884
|
+
}
|
|
901
885
|
}
|
|
902
886
|
/**
|
|
903
887
|
* Whether this request may decide who else reaches `record` — the
|
|
@@ -980,35 +964,46 @@ export class ScopedStack {
|
|
|
980
964
|
/**
|
|
981
965
|
* A `_grant` Record *is* authority, so rewriting one is the escalation
|
|
982
966
|
* UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
|
|
983
|
-
* existing grant rather than minting a fresh one. `
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
967
|
+
* existing grant rather than minting a fresh one. An `_install` decides
|
|
968
|
+
* grants and migration authority, so it is fenced the same way. The verbs
|
|
969
|
+
* that write either live on `Stack`, never `StackClient`, so no scoped
|
|
970
|
+
* write is lost. Writes only: reading one and its history stays on the
|
|
971
|
+
* ordinary gate. See docs/spec/access-control.md § Type-level grants.
|
|
987
972
|
*/
|
|
988
973
|
async requireOwnerForGrantRecord(record) {
|
|
989
|
-
|
|
974
|
+
const family = baseIdOf(record.typeId);
|
|
975
|
+
if (family !== SYSTEM_TYPES.GRANT && family !== SYSTEM_TYPES.INSTALL)
|
|
990
976
|
return;
|
|
991
977
|
if (this.ownerActingAlone)
|
|
992
978
|
return;
|
|
993
|
-
throw await this.denialFor(record,
|
|
979
|
+
throw await this.denialFor(record, `Only the stack owner may write a ${family} record`);
|
|
994
980
|
}
|
|
995
981
|
/**
|
|
996
|
-
* Gated on the write bit alone, which is why the
|
|
997
|
-
* first: authority reached through this verb would be exactly the
|
|
982
|
+
* Gated on the write bit alone, which is why the list is held to its
|
|
983
|
+
* rules first: authority reached through this verb would be exactly the
|
|
998
984
|
* escalation the partition exists to stop, decided before any record is
|
|
999
985
|
* read so it cannot depend on who is asking.
|
|
1000
986
|
*/
|
|
1001
|
-
async associate(id,
|
|
1002
|
-
|
|
1003
|
-
const record = await this.requireUpdatable(id);
|
|
1004
|
-
await this.requireAssociationAccess(record.typeId, association);
|
|
1005
|
-
return this.stack.associate(id, association, this.actor);
|
|
987
|
+
async associate(id, associations) {
|
|
988
|
+
return this.amendAssociations(id, associations.map((association) => ({ op: 'add', association })), 'associate()');
|
|
1006
989
|
}
|
|
1007
990
|
/** See associate() — the same write gate, the same kind refusal. */
|
|
1008
|
-
async dissociate(id,
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
991
|
+
async dissociate(id, associations) {
|
|
992
|
+
return this.amendAssociations(id, associations.map((association) => ({ op: 'remove', association })), 'dissociate()');
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* The write gate, plus the per-element reference gate on each add: what a
|
|
996
|
+
* remove names is already on the record, so it is gated by the write bit
|
|
997
|
+
* alone. See associate().
|
|
998
|
+
*/
|
|
999
|
+
async amendAssociations(id, changes, surface = 'amendAssociations()') {
|
|
1000
|
+
assertAssociationEdits(changes, surface, 'data');
|
|
1001
|
+
const record = await this.requireUpdatable(id);
|
|
1002
|
+
for (const change of changes) {
|
|
1003
|
+
if (change.op === 'add')
|
|
1004
|
+
await this.requireAssociationAccess(record.typeId, change.association);
|
|
1005
|
+
}
|
|
1006
|
+
return this.stack.amendAssociations(id, changes, this.actor, surface);
|
|
1012
1007
|
}
|
|
1013
1008
|
/**
|
|
1014
1009
|
* Extend who reaches a record — the reshare gate's own verb, on the
|
|
@@ -1018,16 +1013,18 @@ export class ScopedStack {
|
|
|
1018
1013
|
* scoping access is for.
|
|
1019
1014
|
* See docs/spec/access-control.md § Record-level permissions.
|
|
1020
1015
|
*/
|
|
1021
|
-
async grantAccess(id,
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1016
|
+
async grantAccess(id, permissions) {
|
|
1017
|
+
return this.amendAccess(id, permissions.map((association) => ({ op: 'add', association })), 'grantAccess()');
|
|
1018
|
+
}
|
|
1019
|
+
/** Withdraw elements of who reaches a record — see grantAccess(). */
|
|
1020
|
+
async revokeAccess(id, permissions) {
|
|
1021
|
+
return this.amendAccess(id, permissions.map((association) => ({ op: 'remove', association })), 'revokeAccess()');
|
|
1025
1022
|
}
|
|
1026
|
-
/**
|
|
1027
|
-
async
|
|
1028
|
-
|
|
1023
|
+
/** The reshare gate over an edit list — see grantAccess(). */
|
|
1024
|
+
async amendAccess(id, changes, surface = 'amendAccess()') {
|
|
1025
|
+
assertAssociationEdits(changes, surface, 'authority');
|
|
1029
1026
|
await this.requireReshareable(id);
|
|
1030
|
-
return this.stack.
|
|
1027
|
+
return this.stack.amendAccess(id, changes, this.actor, surface);
|
|
1031
1028
|
}
|
|
1032
1029
|
/**
|
|
1033
1030
|
* Purge is owner-only: it is irreversible and destroys version
|
|
@@ -1069,9 +1066,9 @@ export class ScopedStack {
|
|
|
1069
1066
|
* already read got that way is not the escalation that fence exists to
|
|
1070
1067
|
* stop. See docs/spec/versioning.md § History access.
|
|
1071
1068
|
*/
|
|
1072
|
-
async getVersions(id) {
|
|
1069
|
+
async getVersions(id, query = {}) {
|
|
1073
1070
|
await this.requireUpdatable(id, { mutating: false });
|
|
1074
|
-
return this.stack.getVersions(id);
|
|
1071
|
+
return this.stack.getVersions(id, query);
|
|
1075
1072
|
}
|
|
1076
1073
|
/**
|
|
1077
1074
|
* See getVersions() — the same mutate-surface gate, for the same reason.
|
|
@@ -1118,8 +1115,10 @@ export class ScopedStack {
|
|
|
1118
1115
|
return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
|
|
1119
1116
|
}
|
|
1120
1117
|
/**
|
|
1121
|
-
* Commit a per-record migration — **the owner acting alone
|
|
1122
|
-
*
|
|
1118
|
+
* Commit a per-record migration — **the owner acting alone**, the same
|
|
1119
|
+
* restriction the bulk `migrateAll()` carries by living on `Stack`, with
|
|
1120
|
+
* one exception: an installed app migrating within its own families (see
|
|
1121
|
+
* installMayMigrate()).
|
|
1123
1122
|
*
|
|
1124
1123
|
* Migrate replaces `content` and `typeId` wholesale, so a grant-based
|
|
1125
1124
|
* version would have to re-derive every gate `create()` applies at the
|
|
@@ -1132,9 +1131,86 @@ export class ScopedStack {
|
|
|
1132
1131
|
* See docs/spec/data-model.md § Type migrations.
|
|
1133
1132
|
*/
|
|
1134
1133
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1135
|
-
this.
|
|
1134
|
+
if (!this.ownerActingAlone && !(await this.installMayMigrate(id, toTypeId))) {
|
|
1135
|
+
throw new StackPermissionError('Only the stack owner, or an installed app within the type families it defines, may commit a migration');
|
|
1136
|
+
}
|
|
1137
|
+
// The new content is a fresh set of file references, so an app is held
|
|
1138
|
+
// to the gate create() applies; otherwise a migration could point one of
|
|
1139
|
+
// its records at any attachment and read the bytes through it.
|
|
1140
|
+
if (!this.ownerActingAlone)
|
|
1141
|
+
await this.requireFileRefAccess(toTypeId, content);
|
|
1136
1142
|
return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
|
|
1137
1143
|
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Whether this request is an installed app migrating a record within its
|
|
1146
|
+
* own families: the app acting as itself, its key linked to the one live
|
|
1147
|
+
* install claiming both families in its own namespace, `toTypeId` a version the owner
|
|
1148
|
+
* approved, and an `update-any` grant on each family made out to the key
|
|
1149
|
+
* directly. Read as data, so a family two installs claim — however that
|
|
1150
|
+
* came to be — confers nothing.
|
|
1151
|
+
*
|
|
1152
|
+
* The reasons migration is otherwise owner-only do not reach this case:
|
|
1153
|
+
* no install can claim a system family, so neither an `_attachment` nor
|
|
1154
|
+
* a DID binding is in reach, and the owner's approval of the version is
|
|
1155
|
+
* the consent. See docs/spec/apps.md § Migrating an installed app's types.
|
|
1156
|
+
*/
|
|
1157
|
+
async installMayMigrate(id, toTypeId) {
|
|
1158
|
+
const principal = this.#principalId;
|
|
1159
|
+
if (!principal || this.delegated)
|
|
1160
|
+
return false;
|
|
1161
|
+
const record = await this.stack.get(id, { includeDeleted: true });
|
|
1162
|
+
if (!record)
|
|
1163
|
+
return false;
|
|
1164
|
+
const families = new Set([baseIdOf(record.typeId), baseIdOf(toTypeId)]);
|
|
1165
|
+
const installs = (await queryAllPages((q) => this.stack.query(q), {
|
|
1166
|
+
filter: { baseId: SYSTEM_TYPES.INSTALL, includeDeleted: true, includeUnlisted: true },
|
|
1167
|
+
}));
|
|
1168
|
+
let install;
|
|
1169
|
+
for (const family of families) {
|
|
1170
|
+
const claimants = installs.filter((r) => claimedFamilies(r.content).has(family));
|
|
1171
|
+
if (claimants.length !== 1)
|
|
1172
|
+
return false;
|
|
1173
|
+
if (familyStanding(family, claimants[0].content.appId) !== 'own')
|
|
1174
|
+
return false;
|
|
1175
|
+
if (install && install.id !== claimants[0].id)
|
|
1176
|
+
return false;
|
|
1177
|
+
install = claimants[0];
|
|
1178
|
+
}
|
|
1179
|
+
if (!install || install.deletedAt)
|
|
1180
|
+
return false;
|
|
1181
|
+
if (!Array.isArray(install.content.defines) || !install.content.defines.includes(toTypeId)) {
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
let linked = false;
|
|
1185
|
+
for (const cardId of linkedIds(install, INSTALL_APP_LABEL)) {
|
|
1186
|
+
const card = await this.stack.get(cardId);
|
|
1187
|
+
if (card && baseIdOf(card.typeId) === SYSTEM_TYPES.APP) {
|
|
1188
|
+
if (card.content.did === principal)
|
|
1189
|
+
linked = true;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (!linked)
|
|
1193
|
+
return false;
|
|
1194
|
+
const grants = await this.loadGrants();
|
|
1195
|
+
for (const family of families) {
|
|
1196
|
+
const held = await this.hasGrant(`${family}@1`, ['update-any'], {
|
|
1197
|
+
grantee: principal,
|
|
1198
|
+
prefetchedGrants: grants,
|
|
1199
|
+
matchOwn: false,
|
|
1200
|
+
allowDefault: false,
|
|
1201
|
+
allowGroup: false,
|
|
1202
|
+
});
|
|
1203
|
+
if (!held)
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
// Asked only once authority is settled, as every mutating verb asks it.
|
|
1207
|
+
// The owner may migrate a tombstone; an app sees it without content.
|
|
1208
|
+
// See docs/spec/apps.md § Migrating an installed app's types.
|
|
1209
|
+
if (record.deletedAt) {
|
|
1210
|
+
throw new StackConflictError(`Record "${id}" is soft-deleted; undelete it before migrating it.`);
|
|
1211
|
+
}
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1138
1214
|
/**
|
|
1139
1215
|
* Store bytes and create an _attachment@1 metadata record (create grant
|
|
1140
1216
|
* on `_attachment@1` required; anonymous denied), returning that record.
|
|
@@ -1169,8 +1245,9 @@ export class ScopedStack {
|
|
|
1169
1245
|
* predicate as the reference-creation gate (canAccessFile).
|
|
1170
1246
|
*/
|
|
1171
1247
|
async getAttachment(fileId) {
|
|
1172
|
-
if (!(await this.canAccessFile(fileId)))
|
|
1173
|
-
throw new StackPermissionError();
|
|
1248
|
+
if (!(await this.canAccessFile(fileId))) {
|
|
1249
|
+
throw new StackPermissionError(`Cannot read file "${fileId}": it does not exist or you cannot read it`);
|
|
1250
|
+
}
|
|
1174
1251
|
return this.stack.getAttachment(fileId);
|
|
1175
1252
|
}
|
|
1176
1253
|
/**
|
|
@@ -1189,17 +1266,12 @@ export class ScopedStack {
|
|
|
1189
1266
|
this.requireOwnerActingAlone('Only the stack owner can collect attachment garbage');
|
|
1190
1267
|
return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
|
|
1191
1268
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
* than an empty or redacted one: the existence of a change is itself a
|
|
1199
|
-
* disclosure, the same reasoning that keeps a count of the whole match
|
|
1200
|
-
* off a query result. See docs/spec/events.md § Permission scoping.
|
|
1201
|
-
*/
|
|
1202
|
-
async subscribe(handler, opts = {}) {
|
|
1269
|
+
async subscribe(first, second, third) {
|
|
1270
|
+
if (isTypeHandle(first)) {
|
|
1271
|
+
return typedSubscribe(this, first, second, third);
|
|
1272
|
+
}
|
|
1273
|
+
const handler = first;
|
|
1274
|
+
const opts = (second ?? {});
|
|
1203
1275
|
this.assertStackOpen();
|
|
1204
1276
|
if (this.relaysChanges) {
|
|
1205
1277
|
throw new RelayScopeError('This stack relays changes from elsewhere, and a scoped view cannot narrow that feed: ' +
|
|
@@ -1209,6 +1281,7 @@ export class ScopedStack {
|
|
|
1209
1281
|
// A relaying stack was already refused above, so relaysChanges is
|
|
1210
1282
|
// always false here — `since` never has a cursor to mean anything by.
|
|
1211
1283
|
assertSinceUsable(opts.since, false);
|
|
1284
|
+
assertValidBaseIdFilter(opts.filter);
|
|
1212
1285
|
if (opts.includeUnlisted && !this.ownerActingAlone) {
|
|
1213
1286
|
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
1214
1287
|
}
|