@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.
Files changed (81) hide show
  1. package/README.md +79 -16
  2. package/dist/access.js +1 -1
  3. package/dist/access.js.map +1 -1
  4. package/dist/adapter-entry.d.ts +2 -1
  5. package/dist/adapter-entry.d.ts.map +1 -1
  6. package/dist/adapter-entry.js +2 -1
  7. package/dist/adapter-entry.js.map +1 -1
  8. package/dist/changes.js +3 -3
  9. package/dist/combine.d.ts.map +1 -1
  10. package/dist/combine.js +2 -3
  11. package/dist/combine.js.map +1 -1
  12. package/dist/errors.d.ts +16 -3
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js +23 -2
  15. package/dist/errors.js.map +1 -1
  16. package/dist/grants.d.ts +9 -1
  17. package/dist/grants.d.ts.map +1 -1
  18. package/dist/grants.js +34 -4
  19. package/dist/grants.js.map +1 -1
  20. package/dist/identity-bindings.d.ts.map +1 -1
  21. package/dist/identity-bindings.js +3 -0
  22. package/dist/identity-bindings.js.map +1 -1
  23. package/dist/index.d.ts +7 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.d.ts +144 -0
  28. package/dist/install.d.ts.map +1 -0
  29. package/dist/install.js +223 -0
  30. package/dist/install.js.map +1 -0
  31. package/dist/query-validation.d.ts +34 -1
  32. package/dist/query-validation.d.ts.map +1 -1
  33. package/dist/query-validation.js +101 -1
  34. package/dist/query-validation.js.map +1 -1
  35. package/dist/record-changes.d.ts +15 -1
  36. package/dist/record-changes.d.ts.map +1 -1
  37. package/dist/record-changes.js +33 -3
  38. package/dist/record-changes.js.map +1 -1
  39. package/dist/record-id.js +1 -1
  40. package/dist/record-id.js.map +1 -1
  41. package/dist/schema.d.ts +6 -7
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +33 -10
  44. package/dist/schema.js.map +1 -1
  45. package/dist/scoped-stack.d.ts +50 -23
  46. package/dist/scoped-stack.d.ts.map +1 -1
  47. package/dist/scoped-stack.js +202 -129
  48. package/dist/scoped-stack.js.map +1 -1
  49. package/dist/stack.d.ts +182 -45
  50. package/dist/stack.d.ts.map +1 -1
  51. package/dist/stack.js +481 -192
  52. package/dist/stack.js.map +1 -1
  53. package/dist/testing.d.ts +28 -11
  54. package/dist/testing.d.ts.map +1 -1
  55. package/dist/testing.js +42 -37
  56. package/dist/testing.js.map +1 -1
  57. package/dist/type-handle.d.ts +157 -0
  58. package/dist/type-handle.d.ts.map +1 -0
  59. package/dist/type-handle.js +131 -0
  60. package/dist/type-handle.js.map +1 -0
  61. package/dist/types.d.ts +91 -20
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +2 -0
  64. package/dist/types.js.map +1 -1
  65. package/dist/validate.d.ts +7 -0
  66. package/dist/validate.d.ts.map +1 -1
  67. package/dist/validate.js +131 -7
  68. package/dist/validate.js.map +1 -1
  69. package/dist/wire-body.d.ts +19 -3
  70. package/dist/wire-body.d.ts.map +1 -1
  71. package/dist/wire-body.js +99 -2
  72. package/dist/wire-body.js.map +1 -1
  73. package/dist/wire-entry.d.ts +2 -2
  74. package/dist/wire-entry.d.ts.map +1 -1
  75. package/dist/wire-entry.js +4 -4
  76. package/dist/wire-entry.js.map +1 -1
  77. package/dist/wire-request.d.ts +18 -1
  78. package/dist/wire-request.d.ts.map +1 -1
  79. package/dist/wire-request.js +33 -1
  80. package/dist/wire-request.js.map +1 -1
  81. package/package.json +1 -1
@@ -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 { StackConflictError, StackError, StackNotFoundError, StackPermissionError, RelayScopeError, StackValidationError, } from './errors.js';
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
- // No content-field prefilter: a stored grant's typeId may be a bare
290
- // baseId or versioned, so exact matching would wrongly exclude family
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
- const record = await this.requireVerb(id, ['update-own', 'update-any'], {
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 allowed = ((await this.checkWrite(record)) ||
454
- (await this.subjectAllows(record.typeId, actions, { record }))) &&
455
- (await this.principalAllows(record.typeId, actions));
456
- if (!allowed)
457
- throw await this.denialFor(record);
458
- return record;
459
- }
460
- /**
461
- * Refuse a mutation aimed at a soft-deleted Record. Asked only of a
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
- * Create a record on behalf of the subject: create grant required,
620
- * anonymous denied, `createdBy` set to the requester, client IDs skew-checked,
621
- * reference-creating options gated, and non-owner `_attachment@1`
622
- * creation refused save one carve-out. A scoped create always stamps
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
- * A record this request may read, or null. Never throws
713
- * StackPermissionError: an unreadable record answers exactly as a missing
714
- * one does, so a caller learns only what it may read.
715
- * See docs/spec/disclosure.md § Which refusal a Record answers with.
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
- * Query records, filtered to those this request can read. Pages are
739
- * filtered then refilled, so a page may slightly overshoot `limit` but
740
- * never skips a record. Grants are prefetched once, cursor-walked to
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
- * Apply a change set on behalf of the subject. Authority is resolved
776
- * **per key** and every gate reads the record as it stands, never as the
777
- * change set would leave it: a widened `permissions` never satisfies the
778
- * read check on a `parentId` named in the same call, and a `_group`
779
- * roster never satisfies the admin check that same call has to pass.
780
- * One refused key refuses the whole call, so nothing is partially
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(id, patch, opts = {}) {
864
- return this.mutate(id, { contentPatch: patch }, opts);
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
- // Reached through its own gate rather than requireUpdatable(), so the
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. `grantType()` and `revokeType()`
984
- * live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
985
- * only: reading a grant Record and its history stays on the ordinary gate.
986
- * See docs/spec/access-control.md § Type-level grants.
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
- if (baseIdOf(record.typeId) !== SYSTEM_TYPES.GRANT)
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, 'Only the stack owner may write a _grant 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 kind refusal comes
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, association) {
1002
- assertDataAssociations([association], 'associate()');
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, association) {
1009
- assertDataAssociations([association], 'dissociate()');
1010
- await this.requireUpdatable(id);
1011
- return this.stack.dissociate(id, association, this.actor);
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, permission) {
1022
- assertAuthorityAssociations([permission], 'grantAccess()');
1023
- await this.requireReshareable(id);
1024
- return this.stack.grantAccess(id, permission, this.actor);
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
- /** Withdraw one element of who reaches a record — see grantAccess(). */
1027
- async revokeAccess(id, permission) {
1028
- assertAuthorityAssociations([permission], 'revokeAccess()');
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.revokeAccess(id, permission, this.actor);
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, only**, the
1122
- * same restriction the bulk `migrateAll()` carries by living on `Stack`.
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.requireOwnerActingAlone('Only the stack owner may commit a migration');
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
- * Observe the changes this request may read. The predicate is canRead
1194
- * applied per event — the same one get() and query() answer with, so a
1195
- * feed can't disagree with them about what this session sees.
1196
- *
1197
- * A record the subscriber cannot read produces no event at all, rather
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
  }