@haverstack/core 0.11.0 → 0.11.2

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/dist/stack.js CHANGED
@@ -17,8 +17,9 @@ import { generateId, isValidIdFormat, idTimestamp } from './id.js';
17
17
  import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
18
18
  import { validateContent, validateReservedKeys } from './validate.js';
19
19
  import { applyMergePatch } from './merge.js';
20
- import { checkAccess, groupRoleFromAssociations } from './access.js';
20
+ import { checkAccess, groupRoleFromAssociations, validatePermissions } from './access.js';
21
21
  import { firstRecordedAttachment } from './attachment-download.js';
22
+ import { ChangeEmitter, Subscription, buildEmission, matchesFilter } from './changes.js';
22
23
  import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
23
24
  // -------------------------------------------------------
24
25
  // Supporting types
@@ -31,6 +32,119 @@ const EMPTY_FAMILY = Symbol('empty-family');
31
32
  * itself derived from — so this can't drift from the type.
32
33
  */
33
34
  const GRANT_ACTION_SET = new Set(GRANT_ACTIONS);
35
+ /**
36
+ * The read actions that make a mutate action coherent, scope for scope: a
37
+ * `-any` verb needs read over the same reach it can mutate, a `-own` verb
38
+ * needs read over its author's own. A grant carrying neither conveys the
39
+ * verb to nobody. `create` has no companion — writing a Record you then
40
+ * cannot read is the drop-box, and it discloses nothing.
41
+ * See docs/spec/access-control.md § Write implies read.
42
+ */
43
+ const READ_COMPANIONS = new Map([
44
+ ['update-own', ['read-own', 'read-any']],
45
+ ['delete-own', ['read-own', 'read-any']],
46
+ ['update-any', ['read-any']],
47
+ ['delete-any', ['read-any']],
48
+ ]);
49
+ /**
50
+ * Whether one grant's action list conveys `action`. The companion has to
51
+ * sit in the same `_grant` Record, not merely somewhere in the grantee's
52
+ * set: a grant is revoked whole, so a rule satisfied across two records
53
+ * would let revoking the read one leave a mutate-without-read grant
54
+ * standing — the configuration this rule exists to refuse, arrived at
55
+ * without anyone writing it.
56
+ */
57
+ function grantConveys(actions, action) {
58
+ if (!actions.includes(action))
59
+ return false;
60
+ const companions = READ_COMPANIONS.get(action);
61
+ return !companions || companions.some((c) => actions.includes(c));
62
+ }
63
+ /** Direct (non-roster) match between a stored _grant's content and a GrantTarget. */
64
+ function matchesGrantTarget(content, target) {
65
+ if (target !== null && typeof target === 'object') {
66
+ // Guarded so an absent groupId can't match the absent granteeGroupId on
67
+ // every entity-targeted and default grant — `undefined === undefined`
68
+ // would otherwise sweep them all into a revoke aimed at one group.
69
+ if (!target.groupId)
70
+ return false;
71
+ return content.granteeGroupId === target.groupId;
72
+ }
73
+ if (target === null) {
74
+ return !content.granteeEntityId && !content.granteeGroupId;
75
+ }
76
+ return content.granteeEntityId === target;
77
+ }
78
+ /**
79
+ * Reject a grant target that names nobody. An empty groupId or entityId is
80
+ * falsy, so a stored record carrying one reads as a *default* grant — every
81
+ * authenticated entity — instead of the target the caller meant. `null` is
82
+ * the only way to say "default".
83
+ */
84
+ function validateGrantTarget(target) {
85
+ if (target === null)
86
+ return;
87
+ if (typeof target === 'string') {
88
+ if (target.length === 0) {
89
+ throw new StackQueryError('A grant target entityId cannot be empty. Pass null for a default grant.');
90
+ }
91
+ return;
92
+ }
93
+ if (typeof target.groupId !== 'string' || target.groupId.length === 0) {
94
+ throw new StackQueryError('A group grant target requires a non-empty groupId.');
95
+ }
96
+ // Not a format check: granteeGroupId is a reference to an existing
97
+ // Record, like parentId or an association's recordId, and none of those
98
+ // are parsed either. One that resolves to nothing simply denies.
99
+ }
100
+ /**
101
+ * Whether a stored _grant covers `grantee`: a direct DID match, roster
102
+ * membership when `allowGroup`, or a default when `allowDefault`. Presence
103
+ * decides the tier, never truthiness — an empty grantee field names nobody.
104
+ * Module-level so the access checks and listGrants() cannot drift apart.
105
+ * See docs/spec/access-control.md § Type-level grants.
106
+ */
107
+ async function grantCoversGrantee(c, grantee, opts) {
108
+ const namesEntity = c.granteeEntityId !== undefined;
109
+ const namesGroup = c.granteeGroupId !== undefined;
110
+ if (!namesEntity && !namesGroup)
111
+ return opts.allowDefault;
112
+ if (namesEntity && c.granteeEntityId !== grantee)
113
+ return false;
114
+ if (namesGroup) {
115
+ if (!opts.allowGroup)
116
+ return false;
117
+ if (!c.granteeGroupId)
118
+ return false;
119
+ const role = await resolveGroupRoleMemoized(c.granteeGroupId, grantee, opts.groupRoles, opts.resolveRecord);
120
+ if (role === null)
121
+ return false;
122
+ }
123
+ return true;
124
+ }
125
+ /**
126
+ * An entity's role on a `_group` roster, memoized in the caller's
127
+ * `groupRoles` map. That map is built per operation and threaded alongside
128
+ * `prefetchedGrants`, so no resolved role outlives the operation that
129
+ * resolved it — removal from a group must never go stale.
130
+ * See docs/spec/access-control.md § Type-level grants.
131
+ */
132
+ async function resolveGroupRoleMemoized(groupId, entityId, groupRoles, resolveRecord) {
133
+ const key = `${groupId}:${entityId}`;
134
+ const cached = groupRoles.get(key);
135
+ if (cached !== undefined)
136
+ return cached;
137
+ const group = await resolveRecord(groupId);
138
+ // Only a real `_group` Record carries a roster. Without the family check
139
+ // any Record's relationship associations would serve as one, and a group
140
+ // migrated out of the family would keep resolving after it had stopped
141
+ // being a group.
142
+ const role = group && baseIdOf(group.typeId) === SYSTEM_TYPES.GROUP
143
+ ? groupRoleFromAssociations(group.associations, entityId)
144
+ : null;
145
+ groupRoles.set(key, role);
146
+ return role;
147
+ }
34
148
  /**
35
149
  * System type families grant() refuses to target: a grant on any of them
36
150
  * would let the grantee mint their own grants, touch stack config, or
@@ -394,10 +508,34 @@ export class Stack {
394
508
  typeCache = new Map();
395
509
  /** Set by close(). See docs/spec/adapters.md § Lifecycle. */
396
510
  closed = false;
511
+ /**
512
+ * Every change made through this Stack. `ScopedStack` filters this same
513
+ * stream rather than opening its own, so no scoped view can observe a
514
+ * change the stack did not emit. See docs/spec/events.md.
515
+ */
516
+ changes = new ChangeEmitter();
397
517
  constructor(adapter, idTimestampSkewMsValue) {
398
518
  this.adapter = adapter;
399
519
  this.idTimestampSkewMsValue = idTimestampSkewMsValue;
400
520
  }
521
+ /**
522
+ * Announce a mutation that has already been persisted. Called after the
523
+ * adapter write resolves and before the mutating method settles, so
524
+ * `await stack.update(...)` guarantees subscribers have been notified —
525
+ * and nothing about work they deferred. A handler cannot fail the write:
526
+ * there is nothing left to fail. See docs/spec/events.md § Handlers.
527
+ */
528
+ emitChange(op, record, opts = {}) {
529
+ this.changes.emit(buildEmission(op, record, opts));
530
+ }
531
+ /**
532
+ * The requester behind a hard delete, which stamps nothing on a record
533
+ * that no longer exists. Owner-acting-alone is the only way to reach the
534
+ * verb, so there is never a principal to name beside the subject.
535
+ */
536
+ static purgeActor(opts) {
537
+ return opts.updatedBy ? { entityId: opts.updatedBy } : undefined;
538
+ }
401
539
  async getTypeCached(id) {
402
540
  const cached = this.typeCache.get(id);
403
541
  if (cached)
@@ -468,7 +606,7 @@ export class Stack {
468
606
  if (opts.onBehalfOf && !entityId) {
469
607
  throw new StackPermissionError('An anonymous principal cannot act on behalf of an entity');
470
608
  }
471
- return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter);
609
+ return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter, this.changes);
472
610
  }
473
611
  /**
474
612
  * Scope to an authenticated session — what StackTokenStore.lookupToken()
@@ -675,7 +813,11 @@ export class Stack {
675
813
  if (!type) {
676
814
  throw new Error(`Unknown type: "${typeId}". Call defineType() first.`);
677
815
  }
678
- const errors = [...validateReservedKeys(content), ...validateContent(content, type.schema)];
816
+ const errors = [
817
+ ...validateReservedKeys(content),
818
+ ...validateContent(content, type.schema),
819
+ ...validatePermissions(opts.permissions),
820
+ ];
679
821
  if (errors.length > 0) {
680
822
  throw new StackValidationError(errors);
681
823
  }
@@ -701,10 +843,17 @@ export class Stack {
701
843
  ...(opts.entityId && { entityId: opts.entityId }),
702
844
  ...(opts.appId && { appId: opts.appId }),
703
845
  ...(opts.principalId && { principalId: opts.principalId }),
846
+ // A create's actor is its author, so these are derived rather than
847
+ // taken: stamping them here keeps "absent means an unscoped write"
848
+ // true of version 1 as it is of every later version.
849
+ ...(opts.entityId && { updatedBy: opts.entityId }),
850
+ ...(opts.principalId && { updatedVia: opts.principalId }),
704
851
  ...(opts.permissions?.length && { permissions: opts.permissions }),
705
852
  ...(associations?.length && { associations }),
706
853
  };
707
- return this.adapter.createRecord(record);
854
+ const created = await this.adapter.createRecord(record);
855
+ this.emitChange('create', created);
856
+ return created;
708
857
  }
709
858
  /**
710
859
  * Apply the registered migration chain in memory, for presentAt:
@@ -787,10 +936,14 @@ export class Stack {
787
936
  if (id === SYSTEM_TYPES.CONFIG) {
788
937
  this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
789
938
  }
790
- return this.adapter.patchContent(id, content, {
939
+ const updated = await this.adapter.patchContent(id, content, {
791
940
  expectedVersion: opts.ifVersion,
792
941
  snapshot: this.buildVersionSnapshot(existing),
942
+ updatedBy: opts.updatedBy,
943
+ updatedVia: opts.updatedVia,
793
944
  });
945
+ this.emitChange('update', updated);
946
+ return updated;
794
947
  }
795
948
  /**
796
949
  * Add an association to a record. Snapshots the record's prior state and
@@ -807,10 +960,13 @@ export class Stack {
807
960
  this.checkIfVersion(existing, opts.ifVersion);
808
961
  if ((existing.associations ?? []).some((a) => associationEqual(a, association)))
809
962
  return;
810
- await this.adapter.associate(id, association, {
963
+ const updated = await this.adapter.associate(id, association, {
811
964
  expectedVersion: opts.ifVersion,
812
965
  snapshot: this.buildVersionSnapshot(existing),
966
+ updatedBy: opts.updatedBy,
967
+ updatedVia: opts.updatedVia,
813
968
  });
969
+ this.emitChange('associate', updated);
814
970
  }
815
971
  /**
816
972
  * Remove an association from a record. Snapshots and bumps version, same
@@ -825,10 +981,13 @@ export class Stack {
825
981
  this.checkIfVersion(existing, opts.ifVersion);
826
982
  if (!(existing.associations ?? []).some((a) => associationEqual(a, association)))
827
983
  return;
828
- await this.adapter.dissociate(id, association, {
984
+ const updated = await this.adapter.dissociate(id, association, {
829
985
  expectedVersion: opts.ifVersion,
830
986
  snapshot: this.buildVersionSnapshot(existing),
987
+ updatedBy: opts.updatedBy,
988
+ updatedVia: opts.updatedVia,
831
989
  });
990
+ this.emitChange('dissociate', updated);
832
991
  }
833
992
  /**
834
993
  * Replace all permissions on a record. Snapshots and bumps version, same
@@ -837,6 +996,10 @@ export class Stack {
837
996
  */
838
997
  async setPermissions(id, permissions, opts = {}) {
839
998
  this.assertOpen();
999
+ const errors = validatePermissions(permissions);
1000
+ if (errors.length > 0) {
1001
+ throw new StackValidationError(errors);
1002
+ }
840
1003
  const existing = await this.adapter.getRecord(id);
841
1004
  if (!existing) {
842
1005
  throw new StackNotFoundError(`Record not found: "${id}"`);
@@ -844,10 +1007,13 @@ export class Stack {
844
1007
  this.checkIfVersion(existing, opts.ifVersion);
845
1008
  if (permissionsEqual(existing.permissions ?? [], permissions))
846
1009
  return;
847
- await this.adapter.setPermissions(id, permissions, {
1010
+ const updated = await this.adapter.setPermissions(id, permissions, {
848
1011
  expectedVersion: opts.ifVersion,
849
1012
  snapshot: this.buildVersionSnapshot(existing),
1013
+ updatedBy: opts.updatedBy,
1014
+ updatedVia: opts.updatedVia,
850
1015
  });
1016
+ this.emitChange('permissions', updated);
851
1017
  }
852
1018
  /**
853
1019
  * Soft-delete a record (default) or hard-delete it permanently, removing
@@ -862,7 +1028,17 @@ export class Stack {
862
1028
  throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
863
1029
  }
864
1030
  if (opts.hard) {
865
- return this.adapter.deleteRecord(id, { hard: true, expectedVersion: opts.ifVersion });
1031
+ // The adapter hands back what it destroyed, captured inside the same
1032
+ // write: a read here instead would race the delete, and afterwards
1033
+ // there is nothing left to read. Null means there was no record, so
1034
+ // nothing was purged and nothing is announced.
1035
+ const purged = await this.adapter.deleteRecord(id, {
1036
+ hard: true,
1037
+ expectedVersion: opts.ifVersion,
1038
+ });
1039
+ if (purged)
1040
+ this.emitChange('hard-delete', purged, { actor: Stack.purgeActor(opts) });
1041
+ return;
866
1042
  }
867
1043
  const existing = await this.adapter.getRecord(id);
868
1044
  if (!existing) {
@@ -871,10 +1047,14 @@ export class Stack {
871
1047
  this.checkIfVersion(existing, opts.ifVersion);
872
1048
  if (existing.deletedAt)
873
1049
  return;
874
- await this.adapter.deleteRecord(id, {
1050
+ const deleted = await this.adapter.deleteRecord(id, {
875
1051
  expectedVersion: opts.ifVersion,
876
1052
  snapshot: this.buildVersionSnapshot(existing),
1053
+ updatedBy: opts.updatedBy,
1054
+ updatedVia: opts.updatedVia,
877
1055
  });
1056
+ if (deleted)
1057
+ this.emitChange('delete', deleted);
878
1058
  }
879
1059
  /**
880
1060
  * Reverse a soft delete. Idempotent — undeleting a record that isn't
@@ -891,10 +1071,14 @@ export class Stack {
891
1071
  this.checkIfVersion(existing, opts.ifVersion);
892
1072
  if (!existing.deletedAt)
893
1073
  return existing;
894
- return this.adapter.undeleteRecord(id, {
1074
+ const undeleted = await this.adapter.undeleteRecord(id, {
895
1075
  expectedVersion: opts.ifVersion,
896
1076
  snapshot: this.buildVersionSnapshot(existing),
1077
+ updatedBy: opts.updatedBy,
1078
+ updatedVia: opts.updatedVia,
897
1079
  });
1080
+ this.emitChange('undelete', undeleted);
1081
+ return undeleted;
898
1082
  }
899
1083
  /**
900
1084
  * Query records. filter.baseId matches every version of a type family,
@@ -988,10 +1172,14 @@ export class Stack {
988
1172
  for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
989
1173
  this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
990
1174
  }
991
- return this.adapter.restoreVersion(id, version, {
1175
+ const restored = await this.adapter.restoreVersion(id, version, {
992
1176
  expectedVersion: opts.ifVersion,
993
1177
  snapshot: this.buildVersionSnapshot(existing),
1178
+ updatedBy: opts.updatedBy,
1179
+ updatedVia: opts.updatedVia,
994
1180
  });
1181
+ this.emitChange('restore', restored);
1182
+ return restored;
995
1183
  }
996
1184
  /**
997
1185
  * Commit a per-record migration: replace `content` and `typeId` together
@@ -1020,7 +1208,7 @@ export class Stack {
1020
1208
  throw new StackNotFoundError(`Record not found: "${id}"`);
1021
1209
  }
1022
1210
  this.checkIfVersion(existing, opts.ifVersion);
1023
- return this.commitMigrationChecked(existing, toTypeId, content, opts.ifVersion);
1211
+ return this.commitMigrationChecked(existing, toTypeId, content, opts);
1024
1212
  }
1025
1213
  /**
1026
1214
  * The checked migration write, shared by commitMigration() and
@@ -1037,7 +1225,7 @@ export class Stack {
1037
1225
  * can cross type families; family-crossing is exactly what the checks
1038
1226
  * below care about.
1039
1227
  */
1040
- async commitMigrationChecked(existing, toTypeId, content, ifVersion) {
1228
+ async commitMigrationChecked(existing, toTypeId, content, opts = {}) {
1041
1229
  const id = existing.id;
1042
1230
  const type = await this.getTypeCached(toTypeId);
1043
1231
  if (!type) {
@@ -1074,10 +1262,14 @@ export class Stack {
1074
1262
  if (id === SYSTEM_TYPES.CONFIG) {
1075
1263
  this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
1076
1264
  }
1077
- return this.adapter.commitMigration(id, toTypeId, content, {
1078
- expectedVersion: ifVersion,
1265
+ const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
1266
+ expectedVersion: opts.ifVersion,
1079
1267
  snapshot: this.buildVersionSnapshot(existing),
1268
+ updatedBy: opts.updatedBy,
1269
+ updatedVia: opts.updatedVia,
1080
1270
  });
1271
+ this.emitChange('migrate', migrated);
1272
+ return migrated;
1081
1273
  }
1082
1274
  /** Uniqueness for every unique binding field a newly created card claims. */
1083
1275
  async checkBindingsOnCreate(typeId, content) {
@@ -1314,7 +1506,10 @@ export class Stack {
1314
1506
  this.assertOpen();
1315
1507
  assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
1316
1508
  if (this.adapter.putAttachmentWithMetadata) {
1509
+ // The metadata record is written inside the adapter, so create()
1510
+ // never sees it and this is the only place it can be announced.
1317
1511
  const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
1512
+ this.emitChange('create', record);
1318
1513
  return record;
1319
1514
  }
1320
1515
  const fileId = await this.adapter.putAttachment(data);
@@ -1334,13 +1529,24 @@ export class Stack {
1334
1529
  * Throws StackConflictError if any record in the stack still references the file.
1335
1530
  * Throws StackNotFoundError if neither metadata records nor bytes exist.
1336
1531
  */
1337
- async deleteAttachment(fileId) {
1532
+ async deleteAttachment(fileId, opts = {}) {
1338
1533
  this.assertOpen();
1339
1534
  const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1340
- const deletedRecordIds = this.adapter.deleteUnreferencedAttachmentRecords
1341
- ? await this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId)
1342
- : await this.deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId);
1343
- if (!deletedRecordIds.length) {
1535
+ let deletedRecords;
1536
+ if (this.adapter.deleteUnreferencedAttachmentRecords) {
1537
+ // Purged inside the adapter's own transaction, so these never reach
1538
+ // delete() and are announced here instead — from the records it
1539
+ // hands back, which are the last copies that will ever exist.
1540
+ deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId);
1541
+ const at = new Date();
1542
+ for (const record of deletedRecords) {
1543
+ this.emitChange('hard-delete', record, { actor: Stack.purgeActor(opts), at });
1544
+ }
1545
+ }
1546
+ else {
1547
+ deletedRecords = await this.deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts);
1548
+ }
1549
+ if (!deletedRecords.length) {
1344
1550
  try {
1345
1551
  await this.adapter.getAttachment(fileId);
1346
1552
  }
@@ -1355,7 +1561,7 @@ export class Stack {
1355
1561
  * deleteUnreferencedAttachmentRecords(): a concurrent associate() can
1356
1562
  * race between the reference check below and the deletes it guards.
1357
1563
  */
1358
- async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId) {
1564
+ async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts = {}) {
1359
1565
  // A soft-deleted record still counts as a reference — it must find its
1360
1566
  // attachments intact on undelete. See docs/spec/attachments.md
1361
1567
  // § Deleting attachments.
@@ -1379,9 +1585,9 @@ export class Stack {
1379
1585
  ? metaResults
1380
1586
  : metaResults.filter((r) => r.content.fileId === fileId);
1381
1587
  for (const record of metaRecords) {
1382
- await this.delete(record.id, { hard: true });
1588
+ await this.delete(record.id, { hard: true, ...opts });
1383
1589
  }
1384
- return metaRecords.map((r) => r.id);
1590
+ return metaRecords;
1385
1591
  }
1386
1592
  /**
1387
1593
  * Sweep for attachment bytes unreachable from any record — live or
@@ -1440,7 +1646,7 @@ export class Stack {
1440
1646
  continue;
1441
1647
  }
1442
1648
  try {
1443
- await this.deleteAttachment(fileId);
1649
+ await this.deleteAttachment(fileId, { updatedBy: opts.updatedBy });
1444
1650
  }
1445
1651
  catch (err) {
1446
1652
  // Raced with a new reference, or another sweep/call already removed
@@ -1463,6 +1669,20 @@ export class Stack {
1463
1669
  * ones that buffer, and for checkpointing a stack that stays open —
1464
1670
  * close() covers the teardown case on its own.
1465
1671
  */
1672
+ /**
1673
+ * Observe every change made through this Stack. Unscoped, so no
1674
+ * permission filter applies — a caller holding a `Stack` already reaches
1675
+ * every record by other means; `ScopedStack.subscribe()` is the filtered
1676
+ * view. See docs/spec/events.md.
1677
+ *
1678
+ * Async so that a remote stack can resolve once its feed is live, which
1679
+ * makes subscribe-then-query the gap-free startup order everywhere. A
1680
+ * local stack is live immediately.
1681
+ */
1682
+ async subscribe(handler, opts = {}) {
1683
+ this.assertOpen();
1684
+ return this.changes.subscribe(handler, opts);
1685
+ }
1466
1686
  async flush() {
1467
1687
  this.assertOpen();
1468
1688
  await this.adapter.flush?.();
@@ -1480,6 +1700,7 @@ export class Stack {
1480
1700
  // half-open and invite a second close() onto an already-closed adapter.
1481
1701
  // Flushes through the adapter directly, past the now-tripped guard.
1482
1702
  this.closed = true;
1703
+ this.changes.closeAll();
1483
1704
  try {
1484
1705
  await this.adapter.flush?.();
1485
1706
  }
@@ -1500,8 +1721,9 @@ export class Stack {
1500
1721
  // -------------------------------------------------------
1501
1722
  /**
1502
1723
  * Create _grant records authorizing entities to act on records of
1503
- * specific types; null entityId writes a default grant (any
1504
- * authenticated entity).
1724
+ * specific types; null target writes a default grant (any authenticated
1725
+ * entity); `{ groupId }` targets a `_group` Record's roster instead of a
1726
+ * single entity.
1505
1727
  *
1506
1728
  * Granting an **app** a `-own` action does not contain it the way the
1507
1729
  * suffix suggests: when that app acts for someone, `-own` is read as the
@@ -1511,51 +1733,71 @@ export class Stack {
1511
1733
  * needs, not the suffix that looks narrowest. See
1512
1734
  * docs/spec/access-control.md § Delegation: principal and subject.
1513
1735
  *
1514
- * The grantee lives in content.granteeEntityId,
1736
+ * The grantee lives in content.granteeEntityId / content.granteeGroupId,
1515
1737
  * not record.entityId. See docs/spec/access-control.md § Type-level
1516
1738
  * grants.
1517
1739
  */
1518
- async grant(entityId, grants) {
1740
+ async grant(target, grants) {
1519
1741
  this.assertOpen();
1742
+ validateGrantTarget(target);
1520
1743
  this.checkGrantsValid(grants);
1521
1744
  const records = [];
1522
1745
  for (const g of grants) {
1523
1746
  records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1524
1747
  typeId: g.typeId,
1525
1748
  actions: g.actions,
1526
- ...(entityId && { granteeEntityId: entityId }),
1749
+ ...(typeof target === 'string' && { granteeEntityId: target }),
1750
+ ...(target !== null && typeof target === 'object' && { granteeGroupId: target.groupId }),
1527
1751
  }));
1528
1752
  }
1529
1753
  return records;
1530
1754
  }
1531
1755
  /**
1532
- * List _grant records. Omit `entityId` for all grants; pass null for
1533
- * only default grants; pass a specific entityId for the grants that
1534
- * currently apply to that entity (ones naming them, plus every default
1535
- * grant) — the same resolution hasGrant() uses.
1756
+ * List _grant records. Omit `target` for all grants; pass null for only
1757
+ * default grants; pass `{ groupId }` for grants naming that exact group;
1758
+ * pass a specific entityId for the grants that currently apply to that
1759
+ * entity (ones naming them, ones naming a group they belong to, plus
1760
+ * every default grant) — the same resolution hasGrant() uses.
1536
1761
  */
1537
- async listGrants(entityId) {
1762
+ async listGrants(target) {
1538
1763
  this.assertOpen();
1764
+ if (target !== undefined)
1765
+ validateGrantTarget(target);
1539
1766
  const all = await queryAllPages((q) => this.query(q), {
1540
1767
  filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
1541
1768
  });
1542
- if (entityId === undefined)
1769
+ if (target === undefined)
1543
1770
  return all;
1544
- return all.filter((r) => {
1545
- const granteeEntityId = r.content.granteeEntityId;
1546
- return entityId === null
1547
- ? !granteeEntityId
1548
- : !granteeEntityId || granteeEntityId === entityId;
1549
- });
1771
+ if (target === null || typeof target === 'object') {
1772
+ return all.filter((r) => matchesGrantTarget(r.content, target));
1773
+ }
1774
+ // target is an EntityId: resolve group rosters, since a grant naming a
1775
+ // group the entity belongs to also currently applies to them. Shares
1776
+ // grantCoversGrantee() with the access checks, so a listing can't
1777
+ // disagree with them about who a grant covers.
1778
+ const groupRoles = new Map();
1779
+ const result = [];
1780
+ for (const r of all) {
1781
+ const covers = await grantCoversGrantee(r.content, target, {
1782
+ allowDefault: true,
1783
+ allowGroup: true,
1784
+ groupRoles,
1785
+ resolveRecord: (id) => this.get(id),
1786
+ });
1787
+ if (covers)
1788
+ result.push(r);
1789
+ }
1790
+ return result;
1550
1791
  }
1551
1792
  /**
1552
- * The inverse of grant(): soft-deletes _grant records matching `entityId`
1793
+ * The inverse of grant(): soft-deletes _grant records matching `target`
1553
1794
  * (null for default grants) and each `{ typeId, actions }` pair, at the
1554
1795
  * same granularity grant() writes. A soft delete like any other — the
1555
1796
  * owner can undelete a revocation.
1556
1797
  */
1557
- async revoke(entityId, grants) {
1798
+ async revoke(target, grants) {
1558
1799
  this.assertOpen();
1800
+ validateGrantTarget(target);
1559
1801
  const all = await queryAllPages((q) => this.query(q), {
1560
1802
  filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
1561
1803
  });
@@ -1566,7 +1808,7 @@ export class Stack {
1566
1808
  const c = r.content;
1567
1809
  if (baseIdOf(c.typeId) !== familyId)
1568
1810
  return false;
1569
- if ((c.granteeEntityId ?? null) !== entityId)
1811
+ if (!matchesGrantTarget(c, target))
1570
1812
  return false;
1571
1813
  return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
1572
1814
  });
@@ -1610,6 +1852,17 @@ export class Stack {
1610
1852
  message: `Cannot grant on "${baseIdOf(g.typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
1611
1853
  });
1612
1854
  }
1855
+ g.actions.forEach((action, j) => {
1856
+ if (grantConveys(g.actions, action))
1857
+ return;
1858
+ const companions = READ_COMPANIONS.get(action);
1859
+ if (!companions)
1860
+ return;
1861
+ errors.push({
1862
+ path: `grants[${i}].actions[${j}]`,
1863
+ message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read`,
1864
+ });
1865
+ });
1613
1866
  });
1614
1867
  if (errors.length > 0) {
1615
1868
  throw new StackValidationError(errors);
@@ -1641,6 +1894,7 @@ export class Stack {
1641
1894
  typeId: { kind: 'string', required: true },
1642
1895
  actions: { kind: 'array', items: { kind: 'string' }, required: true },
1643
1896
  granteeEntityId: { kind: 'string' },
1897
+ granteeGroupId: { kind: 'string' },
1644
1898
  });
1645
1899
  await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
1646
1900
  fileId: { kind: 'string', required: true },
@@ -1674,6 +1928,8 @@ export class Stack {
1674
1928
  content: record.content,
1675
1929
  updatedAt: record.updatedAt,
1676
1930
  ...(record.entityId && { entityId: record.entityId }),
1931
+ ...(record.updatedBy && { updatedBy: record.updatedBy }),
1932
+ ...(record.updatedVia && { updatedVia: record.updatedVia }),
1677
1933
  associations: record.associations ?? [],
1678
1934
  ...(record.permissions && { permissions: record.permissions }),
1679
1935
  };
@@ -1761,23 +2017,128 @@ function stripVersionPermissions(version) {
1761
2017
  * grants). Under delegation both halves apply, and a mistake on the
1762
2018
  * authority side is an escalation rather than a preference.
1763
2019
  */
2020
+ /**
2021
+ * The authority lookups canRead needs, held for the life of one
2022
+ * subscription. A subscription is long-lived where a query is not, so the
2023
+ * cache is only safe because every write that can change canRead's answer
2024
+ * arrives as an event that drops it — see ScopedSubscription.
2025
+ */
2026
+ class FeedAuthorityCache {
2027
+ grantRecords = null;
2028
+ /**
2029
+ * Bumped by every invalidation, so a load that was already in flight can
2030
+ * tell that its result is stale before seating it.
2031
+ */
2032
+ generation = 0;
2033
+ /** Roster roles, memoized per group, as ScopedStack.query() does per query. */
2034
+ roles = new Map();
2035
+ /** Every `_grant` record, refilled through `load` after an invalidation. */
2036
+ async grants(load) {
2037
+ if (this.grantRecords !== null)
2038
+ return this.grantRecords;
2039
+ const generation = this.generation;
2040
+ const loaded = await load();
2041
+ // An invalidation during the load already dropped the set these
2042
+ // replace, so seating them would outlive the write that expired them
2043
+ // and no later event would drop them again. The event being decided
2044
+ // precedes that write, so it is still decided on what was loaded.
2045
+ if (this.generation === generation)
2046
+ this.grantRecords = loaded;
2047
+ return loaded;
2048
+ }
2049
+ invalidateFor(typeFamily) {
2050
+ if (typeFamily === SYSTEM_TYPES.GRANT) {
2051
+ this.grantRecords = null;
2052
+ this.generation++;
2053
+ }
2054
+ if (typeFamily === SYSTEM_TYPES.GROUP)
2055
+ this.roles = new Map();
2056
+ }
2057
+ }
2058
+ /**
2059
+ * A ScopedStack's delivery: canRead per event, with the grant and roster
2060
+ * lookups it needs cached for the life of the subscription and dropped the
2061
+ * moment anything that feeds them changes.
2062
+ *
2063
+ * Two properties do the work, and neither is optional:
2064
+ *
2065
+ * **Deliveries are serialized.** The permission decision is asynchronous,
2066
+ * so without a queue two changes to one record could resolve out of order
2067
+ * and be delivered newest-first — breaking the one ordering guarantee the
2068
+ * feed makes. Every emission is appended to a chain instead.
2069
+ *
2070
+ * **The filter fails closed.** A permission check that throws drops the
2071
+ * event and reports the error; it never delivers on the assumption that a
2072
+ * failed check would have passed.
2073
+ *
2074
+ * See docs/spec/events.md § Permission scoping.
2075
+ */
2076
+ class ScopedSubscription extends Subscription {
2077
+ canRead;
2078
+ cache = new FeedAuthorityCache();
2079
+ /** Tail of the delivery chain — see the class comment. */
2080
+ queue = Promise.resolve();
2081
+ constructor(canRead, handler, opts) {
2082
+ super(handler, opts);
2083
+ this.canRead = canRead;
2084
+ }
2085
+ accept(emission) {
2086
+ // Invalidation reads every emission, including the ones this
2087
+ // subscriber may not see: a revocation the subscriber cannot read is
2088
+ // exactly the one that must still expire its cache.
2089
+ this.invalidateFor(emission);
2090
+ if (!matchesFilter(emission, this.opts.filter))
2091
+ return;
2092
+ this.queue = this.queue.then(() => this.filterAndDeliver(emission));
2093
+ }
2094
+ /**
2095
+ * A cached grant set outlives the write that revokes it unless something
2096
+ * drops it. Both writes that can change canRead's answer — a `_grant`
2097
+ * record, or a `_group` roster — arrive here as ordinary events, which
2098
+ * is what makes the cache safe to hold at all. A future authority change
2099
+ * that did not emit would silently strand it.
2100
+ */
2101
+ invalidateFor(emission) {
2102
+ this.cache.invalidateFor(baseIdOf(emission.change.typeId));
2103
+ }
2104
+ async filterAndDeliver(emission) {
2105
+ if (this.isClosed)
2106
+ return;
2107
+ try {
2108
+ if (!(await this.canRead(emission.record, this.cache)))
2109
+ return;
2110
+ }
2111
+ catch (err) {
2112
+ // Fail closed: an undecided permission question is not a yes.
2113
+ this.reportError(err);
2114
+ return;
2115
+ }
2116
+ this.deliver(emission);
2117
+ }
2118
+ }
1764
2119
  export class ScopedStack {
1765
2120
  stack;
1766
2121
  principalEntityId;
1767
2122
  subjectEntityId;
1768
2123
  idTimestampSkewMs;
1769
2124
  adapter;
2125
+ changes;
1770
2126
  constructor(stack, principalEntityId, subjectEntityId, idTimestampSkewMs,
1771
2127
  // Bytes-storage primitive for putAttachment(). Held directly because
1772
2128
  // ScopedStack always composes bytes + its own create() — the record
1773
2129
  // must carry the subject's entityId and the principal behind it,
1774
2130
  // neither of which the adapter-level atomic capability takes.
1775
- adapter) {
2131
+ adapter,
2132
+ // The stack's own stream, filtered by subscribe(). Held here rather
2133
+ // than reached through a method on Stack so the unfiltered stream
2134
+ // stays inside this module.
2135
+ changes) {
1776
2136
  this.stack = stack;
1777
2137
  this.principalEntityId = principalEntityId;
1778
2138
  this.subjectEntityId = subjectEntityId;
1779
2139
  this.idTimestampSkewMs = idTimestampSkewMs;
1780
2140
  this.adapter = adapter;
2141
+ this.changes = changes;
1781
2142
  }
1782
2143
  get features() {
1783
2144
  return this.stack.features;
@@ -1787,6 +2148,21 @@ export class ScopedStack {
1787
2148
  get delegated() {
1788
2149
  return this.subjectEntityId !== this.principalEntityId;
1789
2150
  }
2151
+ /**
2152
+ * Who this request is, for stamping onto whatever it mutates. Attribution
2153
+ * follows record-level authorship: the subject is the actor, and the
2154
+ * principal is named beside it only when the two differ.
2155
+ * See docs/spec/data-model.md § Authorship and attribution.
2156
+ */
2157
+ get actor() {
2158
+ // Both keys are always present, so spreading this last overrides
2159
+ // anything a caller passed: a scoped requester names itself by making
2160
+ // the request, never by describing itself in the options.
2161
+ return {
2162
+ updatedBy: this.subjectEntityId ?? undefined,
2163
+ updatedVia: this.delegated ? (this.principalEntityId ?? undefined) : undefined,
2164
+ };
2165
+ }
1790
2166
  /**
1791
2167
  * Whether unconditional owner authority applies — the owner acting as
1792
2168
  * itself. The verbs that rest on it are irreversible or disclose the
@@ -1817,9 +2193,13 @@ export class ScopedStack {
1817
2193
  * See docs/spec/access-control.md § Type-level grants.
1818
2194
  */
1819
2195
  async hasGrant(typeId, actions, opts) {
1820
- const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true } = opts;
2196
+ const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
1821
2197
  if (!grantee)
1822
2198
  return false;
2199
+ // Absent when the caller has no operation-scoped map to share — one
2200
+ // call's worth of memoization, which is still every grant record in
2201
+ // this loop naming the same group.
2202
+ const groupRoles = opts.groupRoles ?? new Map();
1823
2203
  const familyId = baseIdOf(typeId);
1824
2204
  // grant() refuses to write these, but a _grant record is an ordinary
1825
2205
  // Record: an unscoped Stack, an import, or a server mapping a request
@@ -1839,22 +2219,29 @@ export class ScopedStack {
1839
2219
  filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
1840
2220
  });
1841
2221
  }
1842
- return grantRecords.some((r) => {
2222
+ for (const r of grantRecords) {
1843
2223
  const c = r.content;
1844
2224
  if (baseIdOf(c.typeId) !== familyId)
1845
- return false;
1846
- if (!c.granteeEntityId && !allowDefault)
1847
- return false;
1848
- if (c.granteeEntityId && c.granteeEntityId !== grantee)
1849
- return false;
1850
- return actions.some((action) => {
1851
- if (!c.actions.includes(action))
2225
+ continue;
2226
+ const covers = await grantCoversGrantee(c, grantee, {
2227
+ allowDefault,
2228
+ allowGroup,
2229
+ groupRoles,
2230
+ resolveRecord: this.resolveRecord,
2231
+ });
2232
+ if (!covers)
2233
+ continue;
2234
+ const matches = actions.some((action) => {
2235
+ if (!grantConveys(c.actions, action))
1852
2236
  return false;
1853
2237
  if (matchOwn && action.endsWith('-own'))
1854
2238
  return record?.entityId === grantee;
1855
2239
  return true;
1856
2240
  });
1857
- });
2241
+ if (matches)
2242
+ return true;
2243
+ }
2244
+ return false;
1858
2245
  }
1859
2246
  /**
1860
2247
  * The principal half of a delegated request's authority: does the app
@@ -1868,6 +2255,11 @@ export class ScopedStack {
1868
2255
  * people who turn up, not software the owner installed — an app reaches
1869
2256
  * only the types named to it, which is the whole of what containment
1870
2257
  * promises.
2258
+ *
2259
+ * Group-targeted grants don't count here either, one step removed: a
2260
+ * roster is editable by any of the group's admins, so authority reaching
2261
+ * a principal through one would let someone other than the owner name an
2262
+ * app to a type. See docs/spec/access-control.md § Type-level grants.
1871
2263
  */
1872
2264
  principalAllows(typeId, actions, prefetchedGrants) {
1873
2265
  if (!this.delegated)
@@ -1879,6 +2271,7 @@ export class ScopedStack {
1879
2271
  prefetchedGrants,
1880
2272
  matchOwn: false,
1881
2273
  allowDefault: false,
2274
+ allowGroup: false,
1882
2275
  });
1883
2276
  }
1884
2277
  /**
@@ -1895,13 +2288,15 @@ export class ScopedStack {
1895
2288
  grantee: this.subjectEntityId,
1896
2289
  record: opts.record,
1897
2290
  prefetchedGrants: opts.prefetchedGrants,
2291
+ groupRoles: opts.groupRoles,
1898
2292
  });
1899
2293
  }
1900
- async canRead(record, prefetchedGrants) {
2294
+ async canRead(record, prefetchedGrants, groupRoles) {
1901
2295
  const reachable = (await this.checkRead(record)) ||
1902
2296
  (await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
1903
2297
  record,
1904
2298
  prefetchedGrants,
2299
+ groupRoles,
1905
2300
  }));
1906
2301
  if (!reachable)
1907
2302
  return false;
@@ -2004,7 +2399,8 @@ export class ScopedStack {
2004
2399
  filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2005
2400
  })
2006
2401
  : undefined;
2007
- const match = await findFirstMatch((q) => this.stack.query(q), { filter: { attachmentFileId: fileId } }, (record) => this.canRead(record, prefetchedGrants));
2402
+ const groupRoles = new Map();
2403
+ const match = await findFirstMatch((q) => this.stack.query(q), { filter: { attachmentFileId: fileId } }, (record) => this.canRead(record, prefetchedGrants, groupRoles));
2008
2404
  return match !== undefined;
2009
2405
  }
2010
2406
  /**
@@ -2182,12 +2578,16 @@ export class ScopedStack {
2182
2578
  filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2183
2579
  })
2184
2580
  : undefined;
2581
+ // Scoped to this query, like prefetchedGrants beside it: every
2582
+ // candidate Record shares one roster resolution per group, and nothing
2583
+ // is carried into the next operation.
2584
+ const groupRoles = new Map();
2185
2585
  let page = { records: [], cursor: query.cursor ?? null, total: null };
2186
2586
  do {
2187
2587
  page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
2188
2588
  totalFetched += page.records.length;
2189
2589
  for (const record of page.records) {
2190
- if (await this.canRead(record, prefetchedGrants))
2590
+ if (await this.canRead(record, prefetchedGrants, groupRoles))
2191
2591
  records.push(record);
2192
2592
  }
2193
2593
  } while (records.length < limit && page.cursor && totalFetched < maxFetched);
@@ -2206,7 +2606,7 @@ export class ScopedStack {
2206
2606
  this.requireOwnerForOwnerDid(record.typeId, content.did);
2207
2607
  }
2208
2608
  await this.requireFileRefAccess(record.typeId, content);
2209
- return this.stack.update(id, content, opts);
2609
+ return this.stack.update(id, content, { ...opts, ...this.actor });
2210
2610
  }
2211
2611
  /**
2212
2612
  * Naming the software behind a key is the trust decision the `_app`
@@ -2298,11 +2698,11 @@ export class ScopedStack {
2298
2698
  async associate(id, association, opts = {}) {
2299
2699
  const record = await this.requireUpdatable(id);
2300
2700
  await this.requireAssociationAccess(record.typeId, association);
2301
- return this.stack.associate(id, association, opts);
2701
+ return this.stack.associate(id, association, { ...opts, ...this.actor });
2302
2702
  }
2303
2703
  async dissociate(id, association, opts = {}) {
2304
2704
  await this.requireUpdatable(id);
2305
- return this.stack.dissociate(id, association, opts);
2705
+ return this.stack.dissociate(id, association, { ...opts, ...this.actor });
2306
2706
  }
2307
2707
  async setPermissions(id, permissions, opts = {}) {
2308
2708
  const record = await this.stack.get(id);
@@ -2328,7 +2728,7 @@ export class ScopedStack {
2328
2728
  throw new StackPermissionError();
2329
2729
  }
2330
2730
  }
2331
- return this.stack.setPermissions(id, permissions, opts);
2731
+ return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
2332
2732
  }
2333
2733
  /**
2334
2734
  * Hard delete is owner-only: it is irreversible and destroys version
@@ -2341,7 +2741,7 @@ export class ScopedStack {
2341
2741
  if (opts.hard && !this.ownerActingAlone) {
2342
2742
  throw new StackPermissionError('Hard delete is owner-only');
2343
2743
  }
2344
- return this.stack.delete(id, opts);
2744
+ return this.stack.delete(id, { ...opts, ...this.actor });
2345
2745
  }
2346
2746
  /**
2347
2747
  * Reverse a soft delete. Gated the same as delete() — undelete is the
@@ -2350,7 +2750,7 @@ export class ScopedStack {
2350
2750
  */
2351
2751
  async undelete(id, opts = {}) {
2352
2752
  await this.requireDeletable(id);
2353
- return this.stack.undelete(id, opts);
2753
+ return this.stack.undelete(id, { ...opts, ...this.actor });
2354
2754
  }
2355
2755
  /**
2356
2756
  * History is the mutation/recovery surface, not a read surface — gated
@@ -2398,7 +2798,7 @@ export class ScopedStack {
2398
2798
  }
2399
2799
  }
2400
2800
  }
2401
- return this.stack.restoreVersion(id, version, opts);
2801
+ return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
2402
2802
  }
2403
2803
  /**
2404
2804
  * Commit a per-record migration — **the owner acting alone, only**.
@@ -2431,7 +2831,7 @@ export class ScopedStack {
2431
2831
  if (!this.ownerActingAlone) {
2432
2832
  throw new StackPermissionError('Only the stack owner may commit a migration');
2433
2833
  }
2434
- return this.stack.commitMigration(id, toTypeId, content, opts);
2834
+ return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
2435
2835
  }
2436
2836
  /**
2437
2837
  * Store bytes and create an _attachment@1 metadata record (create grant
@@ -2482,7 +2882,7 @@ export class ScopedStack {
2482
2882
  if (!this.ownerActingAlone) {
2483
2883
  throw new StackPermissionError('Only the stack owner can delete attachments');
2484
2884
  }
2485
- return this.stack.deleteAttachment(fileId);
2885
+ return this.stack.deleteAttachment(fileId, this.actor);
2486
2886
  }
2487
2887
  /**
2488
2888
  * Sweep for unreferenced attachment bytes and delete them. Only the stack
@@ -2492,7 +2892,35 @@ export class ScopedStack {
2492
2892
  if (!this.ownerActingAlone) {
2493
2893
  throw new StackPermissionError('Only the stack owner can collect attachment garbage');
2494
2894
  }
2495
- return this.stack.collectAttachmentGarbage(opts);
2895
+ return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
2896
+ }
2897
+ /**
2898
+ * Observe the changes this request may read. The predicate is canRead
2899
+ * applied per event — the same one get() and query() answer with, so a
2900
+ * feed can't disagree with them about what this session sees.
2901
+ *
2902
+ * A record the subscriber cannot read produces no event at all, rather
2903
+ * than an empty or redacted one: the existence of a change is itself a
2904
+ * disclosure, the same reasoning that keeps query()'s `total` null.
2905
+ * See docs/spec/events.md § Permission scoping.
2906
+ */
2907
+ async subscribe(handler, opts = {}) {
2908
+ this.stack.assertOpen();
2909
+ return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
2910
+ }
2911
+ /**
2912
+ * canRead for one event, through the subscription's own cache. Grants
2913
+ * are prefetched once and refilled after an invalidation rather than
2914
+ * re-queried per event, which would be a `_grant` scan for every change
2915
+ * the stack makes.
2916
+ */
2917
+ async canReadCached(record, cache) {
2918
+ const grants = this.principalEntityId
2919
+ ? await cache.grants(() => queryAllPages((q) => this.stack.query(q), {
2920
+ filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2921
+ }))
2922
+ : undefined;
2923
+ return this.canRead(record, grants, cache.roles);
2496
2924
  }
2497
2925
  }
2498
2926
  //# sourceMappingURL=stack.js.map