@haverstack/core 0.42.0 → 0.43.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 (150) hide show
  1. package/README.md +5 -7
  2. package/dist/access.d.ts +30 -32
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +40 -98
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter/combine.d.ts.map +1 -1
  7. package/dist/adapter/combine.js +11 -3
  8. package/dist/adapter/combine.js.map +1 -1
  9. package/dist/adapter/index.d.ts +6 -4
  10. package/dist/adapter/index.d.ts.map +1 -1
  11. package/dist/adapter/index.js +4 -3
  12. package/dist/adapter/index.js.map +1 -1
  13. package/dist/adapter/reach.d.ts +14 -0
  14. package/dist/adapter/reach.d.ts.map +1 -0
  15. package/dist/adapter/reach.js +31 -0
  16. package/dist/adapter/reach.js.map +1 -0
  17. package/dist/associations/identity.d.ts +18 -42
  18. package/dist/associations/identity.d.ts.map +1 -1
  19. package/dist/associations/identity.js +60 -84
  20. package/dist/associations/identity.js.map +1 -1
  21. package/dist/associations/validation.d.ts +1 -8
  22. package/dist/associations/validation.d.ts.map +1 -1
  23. package/dist/associations/validation.js +3 -30
  24. package/dist/associations/validation.js.map +1 -1
  25. package/dist/collection.d.ts +7 -11
  26. package/dist/collection.d.ts.map +1 -1
  27. package/dist/collection.js +24 -34
  28. package/dist/collection.js.map +1 -1
  29. package/dist/content-match.d.ts +2 -2
  30. package/dist/content-match.js +2 -2
  31. package/dist/errors.d.ts +23 -8
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +28 -9
  34. package/dist/errors.js.map +1 -1
  35. package/dist/grants.d.ts +3 -3
  36. package/dist/grants.d.ts.map +1 -1
  37. package/dist/grants.js +4 -21
  38. package/dist/grants.js.map +1 -1
  39. package/dist/index.d.ts +5 -5
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/lazy.d.ts +13 -0
  44. package/dist/lazy.d.ts.map +1 -0
  45. package/dist/lazy.js +27 -0
  46. package/dist/lazy.js.map +1 -0
  47. package/dist/merge.d.ts +1 -1
  48. package/dist/merge.js +1 -1
  49. package/dist/query-validation.d.ts +41 -28
  50. package/dist/query-validation.d.ts.map +1 -1
  51. package/dist/query-validation.js +85 -71
  52. package/dist/query-validation.js.map +1 -1
  53. package/dist/record-changes.d.ts +12 -36
  54. package/dist/record-changes.d.ts.map +1 -1
  55. package/dist/record-changes.js +9 -98
  56. package/dist/record-changes.js.map +1 -1
  57. package/dist/scoped-stack/authority-snapshot.d.ts +37 -0
  58. package/dist/scoped-stack/authority-snapshot.d.ts.map +1 -0
  59. package/dist/scoped-stack/authority-snapshot.js +49 -0
  60. package/dist/scoped-stack/authority-snapshot.js.map +1 -0
  61. package/dist/scoped-stack/authority.d.ts +51 -38
  62. package/dist/scoped-stack/authority.d.ts.map +1 -1
  63. package/dist/scoped-stack/authority.js +160 -136
  64. package/dist/scoped-stack/authority.js.map +1 -1
  65. package/dist/scoped-stack/family-policy.d.ts +59 -0
  66. package/dist/scoped-stack/family-policy.d.ts.map +1 -0
  67. package/dist/scoped-stack/family-policy.js +68 -0
  68. package/dist/scoped-stack/family-policy.js.map +1 -0
  69. package/dist/scoped-stack/scoped-stack.d.ts +66 -86
  70. package/dist/scoped-stack/scoped-stack.d.ts.map +1 -1
  71. package/dist/scoped-stack/scoped-stack.js +243 -324
  72. package/dist/scoped-stack/scoped-stack.js.map +1 -1
  73. package/dist/scoped-stack/subscription.d.ts +14 -33
  74. package/dist/scoped-stack/subscription.d.ts.map +1 -1
  75. package/dist/scoped-stack/subscription.js +16 -57
  76. package/dist/scoped-stack/subscription.js.map +1 -1
  77. package/dist/stack/arguments.d.ts +27 -0
  78. package/dist/stack/arguments.d.ts.map +1 -0
  79. package/dist/stack/arguments.js +53 -0
  80. package/dist/stack/arguments.js.map +1 -0
  81. package/dist/stack/attachments.d.ts +1 -1
  82. package/dist/stack/attachments.js +1 -1
  83. package/dist/stack/changes.d.ts +4 -10
  84. package/dist/stack/changes.d.ts.map +1 -1
  85. package/dist/stack/changes.js +8 -8
  86. package/dist/stack/changes.js.map +1 -1
  87. package/dist/stack/client.d.ts +34 -58
  88. package/dist/stack/client.d.ts.map +1 -1
  89. package/dist/stack/install-app.d.ts.map +1 -1
  90. package/dist/stack/install-app.js +7 -5
  91. package/dist/stack/install-app.js.map +1 -1
  92. package/dist/stack/integrity.js +2 -2
  93. package/dist/stack/integrity.js.map +1 -1
  94. package/dist/stack/limits.d.ts +1 -1
  95. package/dist/stack/limits.js +1 -1
  96. package/dist/stack/reads.d.ts +9 -5
  97. package/dist/stack/reads.d.ts.map +1 -1
  98. package/dist/stack/reads.js +24 -4
  99. package/dist/stack/reads.js.map +1 -1
  100. package/dist/stack/stack.d.ts +77 -71
  101. package/dist/stack/stack.d.ts.map +1 -1
  102. package/dist/stack/stack.js +229 -256
  103. package/dist/stack/stack.js.map +1 -1
  104. package/dist/stack/type-grants.d.ts.map +1 -1
  105. package/dist/stack/type-grants.js +4 -4
  106. package/dist/stack/type-grants.js.map +1 -1
  107. package/dist/stack/write-expectation.d.ts +13 -12
  108. package/dist/stack/write-expectation.d.ts.map +1 -1
  109. package/dist/stack/write-expectation.js.map +1 -1
  110. package/dist/stack/write-gate.d.ts +23 -0
  111. package/dist/stack/write-gate.d.ts.map +1 -0
  112. package/dist/stack/write-gate.js +17 -0
  113. package/dist/stack/write-gate.js.map +1 -0
  114. package/dist/testing.d.ts +25 -9
  115. package/dist/testing.d.ts.map +1 -1
  116. package/dist/testing.js +147 -123
  117. package/dist/testing.js.map +1 -1
  118. package/dist/types/adapter.d.ts +90 -31
  119. package/dist/types/adapter.d.ts.map +1 -1
  120. package/dist/types/associations.d.ts +1 -17
  121. package/dist/types/associations.d.ts.map +1 -1
  122. package/dist/types/associations.js +1 -8
  123. package/dist/types/associations.js.map +1 -1
  124. package/dist/types/capabilities.d.ts +8 -8
  125. package/dist/types/capabilities.d.ts.map +1 -1
  126. package/dist/types/events.d.ts +3 -8
  127. package/dist/types/events.d.ts.map +1 -1
  128. package/dist/types/query.d.ts +4 -4
  129. package/dist/types/system.d.ts +1 -1
  130. package/dist/validate.d.ts +2 -0
  131. package/dist/validate.d.ts.map +1 -1
  132. package/dist/validate.js +2 -1
  133. package/dist/validate.js.map +1 -1
  134. package/dist/wire/body.d.ts +32 -9
  135. package/dist/wire/body.d.ts.map +1 -1
  136. package/dist/wire/body.js +69 -8
  137. package/dist/wire/body.js.map +1 -1
  138. package/dist/wire/index.d.ts +4 -4
  139. package/dist/wire/index.d.ts.map +1 -1
  140. package/dist/wire/index.js +3 -3
  141. package/dist/wire/index.js.map +1 -1
  142. package/dist/wire/record.d.ts +1 -8
  143. package/dist/wire/record.d.ts.map +1 -1
  144. package/dist/wire/record.js +0 -48
  145. package/dist/wire/record.js.map +1 -1
  146. package/dist/wire/request.d.ts +18 -18
  147. package/dist/wire/request.d.ts.map +1 -1
  148. package/dist/wire/request.js +41 -36
  149. package/dist/wire/request.js.map +1 -1
  150. package/package.json +1 -1
@@ -20,8 +20,8 @@
20
20
  * refusals.
21
21
  *
22
22
  * Also holds the feed side of the same rule: a subscription's deliveries
23
- * run through the same canRead() a get() answers with, so a feed can never
24
- * disagree with a read about what a session sees.
23
+ * ask the same read reach a get() does, so a feed can never disagree with
24
+ * a read about what a session sees.
25
25
  *
26
26
  * See docs/spec/access-control.md and docs/spec/events.md § Permission scoping.
27
27
  */
@@ -29,20 +29,21 @@ import { baseIdOf } from '../schema.js';
29
29
  import { validatePatchValues } from '../validate.js';
30
30
  import { ChangeEmitter, assertSinceUsable } from '../stack/changes.js';
31
31
  import { SYSTEM_TYPES } from '../types/index.js';
32
- import { StackConflictError, StackNotFoundError, StackPermissionError, RelayScopeError, StackValidationError, } from '../errors.js';
33
- import { assertAssociationEdits, assertAssociationList, assertAuthorityAssociations, assertDataAssociations, } from '../associations/validation.js';
34
- import { associationDelta, associationEqual, editsOf } from '../associations/identity.js';
35
- import { isGroupRecord } from '../access.js';
36
- import { assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidSort, filtersContent, } from '../query-validation.js';
37
- import { bindingFieldsOf } from '../stack/identity-bindings.js';
32
+ import { StackConflictError, StackNotFoundError, StackPermissionError, StackValidationError, } from '../errors.js';
33
+ import { assertAssociationEdits } from '../associations/validation.js';
34
+ import { addsOf, associationEqual } from '../associations/identity.js';
35
+ import { assertQueryCapabilities, assertValidBaseIdFilter, splitQuery, filtersContent, } from '../query-validation.js';
38
36
  import { checkFamily, storedVersionDiffers, WRITE_EXPECTATION, } from '../stack/write-expectation.js';
37
+ import { WRITE_GATE } from '../stack/write-gate.js';
39
38
  import { assertAttachmentSize } from '../stack/limits.js';
40
39
  import { validateIdTimestampSkew, validateRecordId } from '../stack/record-id.js';
41
- import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, findAppCardByDid, lookupEntityByDid, } from '../stack/reads.js';
42
- import { assertNonEmptyChangeSet, presentDeleted, withoutAuthorityChanges, } from '../record-changes.js';
40
+ import { findAppCardByDid, lookupEntityByDid } from '../stack/reads.js';
41
+ import { presentDeleted, withoutAuthorityChanges } from '../record-changes.js';
43
42
  import { createCollection } from '../collection.js';
44
43
  import { uploadContent } from '../stack/attachments.js';
44
+ import { assertAssociationSet, assertContentPatch, assertParentArgument, assertUnlistedArgument, } from '../stack/arguments.js';
45
45
  import { ScopeAuthority } from './authority.js';
46
+ import { familyPolicy, ownerFieldRefusal } from './family-policy.js';
46
47
  import { ScopedSubscription } from './subscription.js';
47
48
  /**
48
49
  * Held by Stack alone: the index re-exports ScopedStack but not this, so
@@ -63,21 +64,21 @@ export class ScopedStack {
63
64
  adapter;
64
65
  changes;
65
66
  assertStackOpen;
66
- relaysChanges;
67
67
  migrations;
68
+ queryReadable;
68
69
  /** Not callable from outside the package — see ScopedStackInit. */
69
70
  constructor(token, init) {
70
71
  if (token !== scopeToken) {
71
72
  throw new TypeError('A ScopedStack is obtained through Stack.asEntity() or Stack.asActor()');
72
73
  }
73
74
  this.stack = init.stack;
74
- this.#authority = new ScopeAuthority(init.stack, init.principalId, init.subjectId);
75
+ this.#authority = new ScopeAuthority(init.stack, init.principalId, init.subjectId, init.queryReadable);
75
76
  this.idTimestampSkewMs = init.idTimestampSkewMs;
76
77
  this.adapter = init.adapter;
77
78
  this.changes = init.changes;
78
79
  this.assertStackOpen = init.assertOpen;
79
- this.relaysChanges = init.relaysChanges;
80
80
  this.migrations = init.migrations;
81
+ this.queryReadable = init.queryReadable;
81
82
  }
82
83
  collection(handle) {
83
84
  this.assertStackOpen();
@@ -106,9 +107,9 @@ export class ScopedStack {
106
107
  // request, never by describing itself in the options.
107
108
  return { actor: this.#authority.requester };
108
109
  }
109
- /** Refuse a create this request holds no grant for — see ScopeAuthority.checkCreateGrant(). */
110
- async requireCreateGrant(typeId) {
111
- if (!(await this.#authority.checkCreateGrant(typeId))) {
110
+ /** Refuse a create outside this request's reach — see ScopeAuthority.canCreate(). */
111
+ async requireCreateGrant(typeId, snapshot) {
112
+ if (!(await this.#authority.canCreate(typeId, snapshot))) {
112
113
  throw new StackPermissionError(`No create grant for type "${typeId}"`);
113
114
  }
114
115
  }
@@ -123,13 +124,10 @@ export class ScopedStack {
123
124
  * the history readers, which borrow this gate without changing anything —
124
125
  * the one way through it a `_grant` Record stays open to.
125
126
  */
126
- async requireUpdatable(id, opts = {}) {
127
+ async requireUpdatable(id, opts, snapshot) {
127
128
  // The `_grant` write fence applies to mutating callers only: reading a
128
129
  // grant Record's history is not the escalation that fence exists to stop.
129
- return this.requireVerb(id, ['update-own', 'update-any'], {
130
- fenceGrantRecord: opts.mutating ?? true,
131
- expect: opts.expect,
132
- });
130
+ return this.requireVerb(id, 'update', { fenceGrantRecord: opts.mutating ?? true, expect: opts.expect }, snapshot);
133
131
  }
134
132
  /**
135
133
  * The write gate both requireUpdatable() and requireDeletable() are: the
@@ -138,41 +136,45 @@ export class ScopedStack {
138
136
  * stricter management rule that no grant reaches.
139
137
  * See docs/spec/access-control.md § Delegation: principal and subject.
140
138
  */
141
- async requireVerb(id, actions, opts) {
139
+ async requireVerb(id, verb, opts, snapshot) {
142
140
  const record = await this.stack.get(id, { includeDeleted: true });
143
141
  if (!record)
144
142
  throw new StackNotFoundError(`Record not found: "${id}"`);
145
143
  // Ahead of the gate, so another family's record is not found whatever
146
144
  // this requester may do to it — the answer a collection's get() gives.
147
145
  checkFamily(record, opts.expect);
148
- if (opts.fenceGrantRecord)
149
- await this.requireOwnerForGrantRecord(record);
150
- if (isGroupRecord(record)) {
146
+ await this.gateVerb(record, verb, opts.fenceGrantRecord, snapshot);
147
+ return record;
148
+ }
149
+ /** requireVerb()'s gate, asked of a record already found in its family. */
150
+ async gateVerb(record, verb, fenceGrantRecord, snapshot) {
151
+ const { id } = record;
152
+ const { writers } = familyPolicy(record.typeId);
153
+ // The owner-alone fence is on writes only: reading how a grant came to
154
+ // be is not the escalation it exists to stop.
155
+ if (fenceGrantRecord)
156
+ await this.requireOwnerAloneWriter(record, snapshot);
157
+ if (writers === 'group-managers') {
151
158
  if (!this.#authority.isGroupManager(record)) {
152
- throw await this.#authority.denialFor(record, `Cannot change group "${id}": only its admins can manage it`);
159
+ throw await this.#authority.denialFor(record, `Cannot change group "${id}": only its admins can manage it`, snapshot);
153
160
  }
154
- return record;
155
- }
156
- const subjectReaches = (await this.#authority.recordPermits(record, 'write')) ||
157
- (await this.#authority.subjectAllows(record.typeId, actions, { record }));
158
- const principalHolds = await this.#authority.principalAllows(record.typeId, actions);
159
- if (!subjectReaches || !principalHolds) {
160
- const verb = actions[0].split('-')[0];
161
- const what = `Cannot ${verb} "${id}" (${record.typeId})`;
162
- throw await this.#authority.denialFor(record, subjectReaches
163
- ? `${what}: the app acting for this entity holds no ${verb} grant`
164
- : `${what}: requires ${actions.join(' or ')}`);
161
+ return;
165
162
  }
166
- return record;
163
+ if (await this.#authority.may(verb, record, snapshot))
164
+ return;
165
+ const what = `Cannot ${verb} "${id}" (${record.typeId})`;
166
+ throw await this.#authority.denialFor(record, (await this.#authority.subjectMay(verb, record, snapshot))
167
+ ? `${what}: the app acting for this entity holds no ${verb} grant`
168
+ : `${what}: requires ${verb}-own or ${verb}-any`, snapshot);
167
169
  }
168
170
  /**
169
171
  * Fetch a record the subject can reach and the principal holds `delete` on
170
172
  * (via permissions or a delete grant), or throw.
171
173
  */
172
- requireDeletable(id, expect) {
174
+ requireDeletable(id, expect, snapshot) {
173
175
  // No soft-delete refusal: undelete() shares this gate, and a tombstone
174
176
  // is exactly what it addresses.
175
- return this.requireVerb(id, ['delete-own', 'delete-any'], { fenceGrantRecord: true, expect });
177
+ return this.requireVerb(id, 'delete', { fenceGrantRecord: true, expect }, snapshot);
176
178
  }
177
179
  /**
178
180
  * Gates file-ref content fields on file access, mirroring the
@@ -181,12 +183,12 @@ export class ScopedStack {
181
183
  * in `content` are checked (a content patch is a merge patch; untouched fields
182
184
  * carry no new reference).
183
185
  */
184
- async requireFileRefAccess(typeId, content) {
186
+ async requireFileRefAccess(typeId, content, snapshot) {
185
187
  for (const field of await this.#authority.fileRefFieldNames(typeId)) {
186
188
  const value = content[field];
187
189
  if (typeof value !== 'string')
188
190
  continue;
189
- if (!(await this.#authority.canAccessFile(value))) {
191
+ if (!(await this.#authority.canAccessFile(value, snapshot))) {
190
192
  throw new StackPermissionError(`Cannot reference file "${value}" in field "${field}": it does not exist or you cannot read it`);
191
193
  }
192
194
  }
@@ -198,13 +200,13 @@ export class ScopedStack {
198
200
  * stricter management gate. See docs/spec/access-control.md
199
201
  * § Reference-creation gating.
200
202
  */
201
- async requireAssociationAccess(typeId, association) {
203
+ async requireAssociationAccess(typeId, association, snapshot) {
202
204
  if (association.kind === 'attachment') {
203
- if (!(await this.#authority.canAccessFile(association.fileId))) {
205
+ if (!(await this.#authority.canAccessFile(association.fileId, snapshot))) {
204
206
  throw new StackPermissionError(`Cannot reference file "${association.fileId}": it does not exist or you cannot read it`);
205
207
  }
206
208
  }
207
- else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
209
+ else if (association.kind === 'relationship' && !familyPolicy(typeId).rosterAssociations) {
208
210
  const { target } = association;
209
211
  // `stackUrl` is tested for a value, not for presence: absent and
210
212
  // empty are one target — storage, targetEqual() and the filter all
@@ -212,7 +214,7 @@ export class ScopedStack {
212
214
  // leave one spelling of a local Record ungated.
213
215
  if (target.kind !== 'record' || target.stackUrl)
214
216
  return;
215
- if (!(await this.#authority.canReadReferent(target.recordId))) {
217
+ if (!(await this.#authority.canReadReferent(target.recordId, snapshot))) {
216
218
  throw new StackPermissionError(`Cannot reference record "${target.recordId}": it does not exist or you cannot read it`);
217
219
  }
218
220
  }
@@ -236,14 +238,17 @@ export class ScopedStack {
236
238
  !this.#authority.ownerActingAlone) {
237
239
  throw new StackPermissionError('createdAt/updatedAt can only be set by the stack owner acting alone; a grantee or delegated create always stamps the current time.');
238
240
  }
239
- await this.requireCreateGrant(typeId);
241
+ const snapshot = this.#authority.snapshot();
242
+ await this.requireCreateGrant(typeId, snapshot);
240
243
  // The exemption is the owner's own, so delegation doesn't carry it: an
241
244
  // owner principal acting for someone else would otherwise let that
242
245
  // subject name any fileId and reach the bytes through the uploader
243
246
  // clause, which matches on the subject this create stamps.
244
- if (!this.#authority.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
245
- const fileId = content.fileId;
246
- if (typeof fileId !== 'string' || !(await this.#authority.hasReadableReference(fileId))) {
247
+ const namesFile = familyPolicy(typeId).createNamesFile;
248
+ if (!this.#authority.ownerActingAlone && namesFile !== undefined) {
249
+ const fileId = content[namesFile];
250
+ if (typeof fileId !== 'string' ||
251
+ !(await this.#authority.hasReadableReference(fileId, snapshot))) {
247
252
  throw new StackPermissionError(`Cannot reference file "${String(fileId)}": it does not exist or you cannot read it`);
248
253
  }
249
254
  }
@@ -253,7 +258,7 @@ export class ScopedStack {
253
258
  if (opts.unlisted && !this.#authority.mayGrantAccess()) {
254
259
  throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
255
260
  }
256
- this.requireOwnerForOwnerDid(typeId, content.did);
261
+ this.requireOwnerFields(typeId, undefined, content);
257
262
  await this.requireAppIdMatchesPrincipal(opts.appId);
258
263
  if (opts.id !== undefined) {
259
264
  validateRecordId(opts.id);
@@ -264,13 +269,14 @@ export class ScopedStack {
264
269
  validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
265
270
  }
266
271
  }
267
- if (opts.parentId !== undefined && !(await this.#authority.canReadReferent(opts.parentId))) {
272
+ if (opts.parentId !== undefined &&
273
+ !(await this.#authority.canReadReferent(opts.parentId, snapshot))) {
268
274
  throw new StackPermissionError(`Cannot reference record "${opts.parentId}" as parent: it does not exist or you cannot read it`);
269
275
  }
270
276
  for (const assoc of opts.associations ?? []) {
271
- await this.requireAssociationAccess(typeId, assoc);
277
+ await this.requireAssociationAccess(typeId, assoc, snapshot);
272
278
  }
273
- await this.requireFileRefAccess(typeId, content);
279
+ await this.requireFileRefAccess(typeId, content, snapshot);
274
280
  return this.stack.create(typeId, content, {
275
281
  ...opts,
276
282
  createdBy: this.#authority.requester,
@@ -286,14 +292,14 @@ export class ScopedStack {
286
292
  const record = await this.stack.get(id, opts);
287
293
  if (!record)
288
294
  return null;
289
- if (!(await this.#authority.canRead(record)))
295
+ if (!(await this.#authority.canRead(record, this.#authority.snapshot())))
290
296
  return null;
291
297
  return presentDeleted(record);
292
298
  }
293
299
  /**
294
- * Runs through this.query(), so canRead() still applies — this answers a
295
- * question the caller asks on its own behalf. includeUnlisted is passed
296
- * only when the request is the owner acting alone, per
300
+ * Runs through this.query(), so the read reach still applies — this
301
+ * answers a question the caller asks on its own behalf. includeUnlisted
302
+ * is passed only when the request is the owner acting alone, per
297
303
  * docs/spec/identity.md § DID bindings.
298
304
  */
299
305
  async getEntityByDid(did) {
@@ -303,177 +309,173 @@ export class ScopedStack {
303
309
  return this.getEntityByDid(this.stack.ownerEntityId);
304
310
  }
305
311
  /**
306
- * Query records, filtered to those this request can read. Pages are
307
- * filtered then refilled, so a page may slightly overshoot `limit` but
308
- * never skips a record. Grants are prefetched once, cursor-walked to
309
- * exhaustion.
312
+ * Query records, among those this request can read: the adapter matches
313
+ * the request's read reach as one more condition of the query, so a page,
314
+ * its cursor and whether one comes back are decided among readable
315
+ * Records alone. See docs/spec/disclosure.md § What a query reveals.
310
316
  */
311
317
  async query(query = {}) {
312
- assertValidSort(query.sort);
313
- assertSortCapability(query.sort, this.stack.capabilities);
314
- assertValidAssociationFilters(query.filter);
315
- assertValidBaseIdFilter(query.filter);
318
+ const split = splitQuery(query);
319
+ assertQueryCapabilities(split.query, this.stack.capabilities);
316
320
  if (query.filter?.includeUnlisted && !this.#authority.ownerActingAlone) {
317
321
  throw new StackPermissionError('includeUnlisted is owner-only');
318
322
  }
319
- const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
320
- const records = [];
321
- const maxFetched = limit * 10;
322
- let totalFetched = 0;
323
- const prefetchedGrants = this.#authority.identified
324
- ? await this.#authority.loadGrants()
325
- : undefined;
326
- // Scoped to this query, like prefetchedGrants beside it: every
327
- // candidate Record shares one roster resolution per group, and nothing
328
- // is carried into the next operation.
329
- const groupRoles = new Map();
330
- let page = { records: [], cursor: query.cursor ?? null };
331
- do {
332
- page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
333
- totalFetched += page.records.length;
334
- for (const record of page.records) {
335
- // Projected here as well as in get(), or `includeDeleted` would be
336
- // a strictly better read channel than the fetch-by-ID it is
337
- // supposed to match.
338
- if (await this.#authority.canRead(record, prefetchedGrants, groupRoles)) {
339
- records.push(presentDeleted(record));
340
- }
341
- }
342
- } while (records.length < limit && page.cursor && totalFetched < maxFetched);
343
- return { records, cursor: page.cursor };
344
- }
345
- /**
346
- * Apply a change set on behalf of the subject. Each key is gated against
347
- * the record as it stands, never as the set would leave it, and one
348
- * refused key refuses the call. See docs/spec/access-control.md
349
- * § Composing a change set.
350
- */
351
- async mutate(id, changes, opts = {}) {
352
- // Ahead of every gate: a malformed change set is a validation error
353
- // for every requester, rather than one for the owner and a permission
354
- // refusal for everyone else.
355
- assertNonEmptyChangeSet(changes);
356
- if (changes.contentPatch) {
357
- const patchErrors = validatePatchValues(changes.contentPatch);
358
- if (patchErrors.length > 0)
359
- throw new StackValidationError(patchErrors);
360
- }
361
- // Refused here as well as in `Stack` below, so the partition is a
362
- // refusal rather than a permission question: an `associations` key
363
- // naming an authority kind is the wrong surface for every requester,
364
- // owner included.
365
- if (changes.associations)
366
- assertDataAssociations(changes.associations, 'associations');
367
- if (changes.permissions)
368
- assertAuthorityAssociations(changes.permissions, 'permissions');
369
- const writes = changes.contentPatch !== undefined ||
370
- changes.associations !== undefined ||
371
- changes.parentId !== undefined;
372
- // `unlisted` reshares on presence alone; a restated `permissions` set
373
- // does not, which permissionsMove() decides once the record is in hand.
374
- // See docs/spec/access-control.md § Storage unifies; the API does not.
375
- const unlists = changes.unlisted !== undefined;
376
- // requireUpdatable() reads the record and applies the write gate; the
377
- // reshare keys need the record before their own gate, and a change set
378
- // carrying only those must not be held to the write gate it doesn't
379
- // need. One read either way.
380
- const record = writes
381
- ? await this.requireUpdatable(id, { expect: opts })
382
- : await this.requireReshareable(id, opts);
383
- // A key the gate read as inert is dropped, not forwarded: `Stack`
384
- // recomputes the delta from its own read, and must not write an ACL on
385
- // the strength of this one. See docs/spec/access-control.md
386
- // § Storage unifies; the API does not.
387
- let authorized = changes;
388
- if (writes) {
389
- const moves = this.permissionsMove(record, changes);
390
- if (unlists || moves)
391
- await this.requireReshareOf(record);
392
- if (changes.permissions !== undefined && !moves && !this.#authority.canReshare(record)) {
393
- const { permissions: _inert, ...rest } = changes;
394
- authorized = rest;
395
- }
396
- }
323
+ const reach = await this.#authority.snapshot().reach('read');
324
+ const page = await this.queryReadable(split, reach);
325
+ // Projected here as well as in get(), or `includeDeleted` would be a
326
+ // strictly better read channel than the fetch-by-ID it is supposed to
327
+ // match.
328
+ return { records: page.records.map(presentDeleted), cursor: page.cursor };
329
+ }
330
+ /**
331
+ * Patch content on behalf of the subject, on the write gate, plus the
332
+ * gates a content value can carry: a card's binding fields and the files
333
+ * a `file-ref` field names. See docs/spec/access-control.md § Per-verb gates.
334
+ */
335
+ async patchContent(id, patch, opts = {}) {
336
+ // Every argument check sits ahead of the gate, here and in each write
337
+ // verb below, so a malformed call earns one refusal from every requester.
338
+ assertContentPatch(patch);
339
+ const patchErrors = validatePatchValues(patch);
340
+ if (patchErrors.length > 0)
341
+ throw new StackValidationError(patchErrors);
342
+ const snapshot = this.#authority.snapshot();
343
+ const record = await this.requireUpdatable(id, { expect: opts }, snapshot);
397
344
  // A patch for another version is refused by `Stack`, which owes the
398
345
  // tombstone and `ifVersion` refusals first; the content gates would read
399
346
  // a stored Type it was not written for. Pinned to this read, `Stack`
400
347
  // cannot find a record the patch lands on.
401
- const mismatched = storedVersionDiffers(record, opts, changes.contentPatch !== undefined);
402
- if (changes.contentPatch && !mismatched) {
403
- const patch = changes.contentPatch;
348
+ const mismatched = storedVersionDiffers(record, opts, true);
349
+ if (!mismatched) {
404
350
  // Value-wise, not presence-wise: a client that reads a card, edits
405
351
  // its `name` and sends the whole content object back is not setting
406
352
  // the binding it round-trips, and `name` is writable by record-level
407
- // permission. Same predicate restoreVersion() applies to a snapshot.
408
- this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
409
- // Likewise value-wise: re-sending the DID a card already holds
410
- // claims nothing, and immutability refuses changing it regardless.
411
- if ('did' in patch && patch.did !== record.content.did) {
412
- this.requireOwnerForOwnerDid(record.typeId, patch.did);
413
- }
414
- await this.requireFileRefAccess(record.typeId, patch);
353
+ // permission.
354
+ const content = record.content;
355
+ this.requireOwnerFields(record.typeId, content, { ...content, ...patch });
356
+ await this.requireFileRefAccess(record.typeId, patch, snapshot);
415
357
  }
416
- // Every association the change set would add is a reference this
417
- // requester has to be allowed to create — asked against the stored
418
- // set, so an association already on the record is not re-gated.
419
- for (const assoc of changes.associations ?? []) {
420
- if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
421
- continue;
422
- await this.requireAssociationAccess(record.typeId, assoc);
358
+ const ifVersion = mismatched ? (opts.ifVersion ?? record.version) : opts.ifVersion;
359
+ return this.stack.patchContent(id, patch, { ...opts, ifVersion, ...this.actor });
360
+ }
361
+ /**
362
+ * The write gate, plus read access to the destination: a container is a
363
+ * reference the requester has to be allowed to create.
364
+ */
365
+ async setParent(id, parentId, opts = {}) {
366
+ assertParentArgument(parentId);
367
+ const snapshot = this.#authority.snapshot();
368
+ const record = await this.requireUpdatable(id, { expect: opts }, snapshot);
369
+ if (typeof parentId === 'string' &&
370
+ parentId !== (record.parentId ?? null) &&
371
+ !(await this.#authority.canReadReferent(parentId, snapshot))) {
372
+ throw new StackPermissionError(`Cannot reference record "${parentId}" as parent: it does not exist or you cannot read it`);
423
373
  }
424
- if (changes.parentId != null && !(await this.#authority.canReadReferent(changes.parentId))) {
425
- throw new StackPermissionError(`Cannot reference record "${changes.parentId}" as parent: it does not exist or you cannot read it`);
374
+ return this.stack.setParent(id, parentId, { ...opts, ...this.actor });
375
+ }
376
+ /**
377
+ * Listing decides who can enumerate a record, so it carries the reshare
378
+ * gate rather than the write bit. See docs/spec/unlisted.md.
379
+ */
380
+ async setUnlisted(id, unlisted, opts = {}) {
381
+ assertUnlistedArgument(unlisted);
382
+ await this.requireReshareable(id, opts, this.#authority.snapshot());
383
+ return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
384
+ }
385
+ /**
386
+ * The write gate, plus the reference gate on each add new to the record:
387
+ * a remove, or an add of an identity already held, names a reference the
388
+ * record carries, so it is gated by the write bit alone.
389
+ */
390
+ async editAssociations(id, edits, opts = {}) {
391
+ assertAssociationEdits(edits, 'editAssociations()', 'data');
392
+ const snapshot = this.#authority.snapshot();
393
+ return this.stack.editAssociations(id, edits, {
394
+ ...opts,
395
+ ...this.actor,
396
+ [WRITE_GATE]: (record) => this.gateAssociationWrite(record, addsOf(edits), snapshot),
397
+ });
398
+ }
399
+ /** editAssociations()'s gates, with every element new to the set an add. */
400
+ async setAssociations(id, associations, opts = {}) {
401
+ assertAssociationSet(associations, 'associations');
402
+ const snapshot = this.#authority.snapshot();
403
+ return this.stack.setAssociations(id, associations, {
404
+ ...opts,
405
+ ...this.actor,
406
+ [WRITE_GATE]: (record) => this.gateAssociationWrite(record, associations, snapshot),
407
+ });
408
+ }
409
+ /**
410
+ * The write gate, then the reference gate over the elements a write adds,
411
+ * asked against the stored set so an association the record already
412
+ * carries is not re-gated. `Stack` asks it of each read it writes from,
413
+ * so a retried write is gated on the set it lands on.
414
+ */
415
+ async gateAssociationWrite(record, added, snapshot) {
416
+ await this.gateVerb(record, 'update', true, snapshot);
417
+ for (const association of added) {
418
+ if ((record.associations ?? []).some((a) => associationEqual(a, association)))
419
+ continue;
420
+ await this.requireAssociationAccess(record.typeId, association, snapshot);
426
421
  }
427
- const ifVersion = mismatched ? (opts.ifVersion ?? record.version) : opts.ifVersion;
428
- return this.stack.mutate(id, authorized, { ...opts, ifVersion, ...this.actor });
429
422
  }
430
- async patchContent(id, patch, opts) {
431
- return this.mutate(id, { contentPatch: patch }, opts);
423
+ /**
424
+ * Extend or withdraw who reaches a record, on the reshare gate. The write
425
+ * bit does not confer it, or a write-holder could decide who else reaches
426
+ * the record. See docs/spec/access-control.md § Record-level permissions.
427
+ */
428
+ async editAccess(id, edits, opts = {}) {
429
+ assertAssociationEdits(edits, 'editAccess()', 'authority');
430
+ const snapshot = this.#authority.snapshot();
431
+ return this.stack.editAccess(id, edits, {
432
+ ...opts,
433
+ ...this.actor,
434
+ [WRITE_GATE]: (record) => this.gateReshare(record, snapshot),
435
+ });
436
+ }
437
+ /** See editAccess() — the same gate. */
438
+ async setAccess(id, permissions, opts = {}) {
439
+ assertAssociationSet(permissions, 'permissions');
440
+ const snapshot = this.#authority.snapshot();
441
+ return this.stack.setAccess(id, permissions, {
442
+ ...opts,
443
+ ...this.actor,
444
+ [WRITE_GATE]: (record) => this.gateReshare(record, snapshot),
445
+ });
432
446
  }
433
447
  /**
434
448
  * The record, having established that this request may decide who else
435
449
  * reaches it — the owner-or-creator rule, asked of both sides of a
436
- * delegation, or a Group's admin rule for a `_group`. The gate the
437
- * `permissions` and `unlisted` keys carry, which the write bit
438
- * deliberately does not confer.
450
+ * delegation, or a Group's admin rule for a `_group`. The gate the access
451
+ * and listing verbs carry, which the write bit deliberately does not confer.
439
452
  */
440
- async requireReshareable(id, expect) {
453
+ async requireReshareable(id, expect, snapshot) {
441
454
  const record = await this.stack.get(id, { includeDeleted: true });
442
455
  if (!record)
443
456
  throw new StackNotFoundError(`Record not found: "${id}"`);
444
457
  checkFamily(record, expect);
445
- await this.requireOwnerForGrantRecord(record);
446
- await this.requireReshareOf(record);
458
+ await this.gateReshare(record, snapshot);
447
459
  return record;
448
460
  }
449
- /**
450
- * Whether a change set's `permissions` key moves the ACL, read off the
451
- * computed delta so a replacement that drops everything — naming nothing
452
- * — still counts. See docs/spec/access-control.md § Record-level permissions.
453
- */
454
- permissionsMove(record, changes) {
455
- if (changes.permissions === undefined)
456
- return false;
457
- return associationDelta(record.permissions ?? [], changes.permissions).length > 0;
458
- }
459
- /** The reshare decision alone, for a record already read and write-gated. */
460
- async requireReshareOf(record) {
461
+ /** requireReshareable()'s gate, asked of a record already found in its family. */
462
+ async gateReshare(record, snapshot) {
463
+ await this.requireOwnerAloneWriter(record, snapshot);
461
464
  if (!this.#authority.canReshare(record)) {
462
- throw await this.#authority.denialFor(record, `Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`);
465
+ throw await this.#authority.denialFor(record, `Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`, snapshot);
463
466
  }
464
467
  }
465
468
  /**
466
- * An `_app` card's `did` and `appId` are the owner's alone to set, or
467
- * record-level `write` would be a second way to name the software behind
468
- * a key. `_entity` cards stay writable by grant, as a contacts app needs.
469
- * See docs/spec/identity.md § DID bindings.
469
+ * Refuse a write moving content its family reserves to the owner acting
470
+ * alone — an `_app` card's bindings, the owner's own DID on an `_entity`
471
+ * — whichever verb carries it. See docs/spec/identity.md § DID bindings.
470
472
  */
471
- requireOwnerForAppIdentity(typeId, touches) {
472
- if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
473
- return;
474
- if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
473
+ requireOwnerFields(typeId, before, after) {
474
+ if (this.#authority.ownerActingAlone)
475
475
  return;
476
- this.requireOwnerActingAlone('Only the stack owner may set an _app record’s did or appId');
476
+ const refusal = ownerFieldRefusal(typeId, before, after, this.stack.ownerEntityId);
477
+ if (refusal)
478
+ throw new StackPermissionError(refusal);
477
479
  }
478
480
  /**
479
481
  * A delegated write's `appId` must match the `_app` card the owner
@@ -491,79 +493,17 @@ export class ScopedStack {
491
493
  }
492
494
  }
493
495
  /**
494
- * The owner's own DID is the one `_entity` binding a grantee may not
495
- * claim: `ownerProfile` adopts whichever card holds it.
496
- * See docs/spec/identity.md § DID bindings.
497
- */
498
- requireOwnerForOwnerDid(typeId, did) {
499
- if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
500
- return;
501
- if (did !== this.stack.ownerEntityId)
502
- return;
503
- this.requireOwnerActingAlone('Only the stack owner may claim the owner’s own did');
504
- }
505
- /**
506
- * `_grant` and `_install` Records are authority, so only the owner acting
507
- * alone may write one; reading them stays on the ordinary gate.
496
+ * Refuse anyone but the owner acting alone a write to a family only the
497
+ * owner writes — `_grant` and `_install`, which are authority — with the
498
+ * refusal an unreadable Record answers with. Any other family passes.
508
499
  * See docs/spec/access-control.md § What a grant covers.
509
500
  */
510
- async requireOwnerForGrantRecord(record) {
511
- const family = baseIdOf(record.typeId);
512
- if (family !== SYSTEM_TYPES.GRANT && family !== SYSTEM_TYPES.INSTALL)
501
+ async requireOwnerAloneWriter(record, snapshot) {
502
+ if (familyPolicy(record.typeId).writers !== 'owner-alone')
513
503
  return;
514
504
  if (this.#authority.ownerActingAlone)
515
505
  return;
516
- throw await this.#authority.denialFor(record, `Only the stack owner may write a ${family} record`);
517
- }
518
- /**
519
- * Gated on the write bit alone, which is why the list is held to its
520
- * rules first: authority reached through this verb would be exactly the
521
- * escalation the partition exists to stop, decided before any record is
522
- * read so it cannot depend on who is asking.
523
- */
524
- async associate(id, associations, opts = {}) {
525
- assertAssociationList(associations, 'associate()', 'associations', 'data');
526
- return this.amendAssociations(id, editsOf('add', associations), opts, 'associate()');
527
- }
528
- /** See associate() — the same write gate, the same kind refusal. */
529
- async dissociate(id, associations, opts = {}) {
530
- assertAssociationList(associations, 'dissociate()', 'associations', 'data');
531
- return this.amendAssociations(id, editsOf('remove', associations), opts, 'dissociate()');
532
- }
533
- /**
534
- * The write gate, plus the per-element reference gate on each add: what a
535
- * remove names is already on the record, so it is gated by the write bit
536
- * alone. See associate().
537
- */
538
- async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
539
- assertAssociationEdits(changes, surface, 'data');
540
- const record = await this.requireUpdatable(id, { expect: opts });
541
- for (const change of changes) {
542
- if (change.op === 'add')
543
- await this.requireAssociationAccess(record.typeId, change.association);
544
- }
545
- return this.stack.amendAssociations(id, changes, { ...opts, ...this.actor }, surface);
546
- }
547
- /**
548
- * Extend who reaches a record, on the reshare gate the `permissions` key
549
- * carries. The write bit does not confer it, or a write-holder could
550
- * decide who else reaches the record.
551
- * See docs/spec/access-control.md § Record-level permissions.
552
- */
553
- async grantAccess(id, permissions, opts = {}) {
554
- assertAssociationList(permissions, 'grantAccess()', 'permissions', 'authority');
555
- return this.amendAccess(id, editsOf('add', permissions), opts, 'grantAccess()');
556
- }
557
- /** Withdraw elements of who reaches a record — see grantAccess(). */
558
- async revokeAccess(id, permissions, opts = {}) {
559
- assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
560
- return this.amendAccess(id, editsOf('remove', permissions), opts, 'revokeAccess()');
561
- }
562
- /** The reshare gate over an edit list — see grantAccess(). */
563
- async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
564
- assertAssociationEdits(changes, surface, 'authority');
565
- await this.requireReshareable(id, opts);
566
- return this.stack.amendAccess(id, changes, { ...opts, ...this.actor }, surface);
506
+ throw await this.#authority.denialFor(record, `Only the stack owner may write a ${baseIdOf(record.typeId)} record`, snapshot);
567
507
  }
568
508
  /**
569
509
  * Purge is owner-only: it is irreversible and destroys version
@@ -572,31 +512,24 @@ export class ScopedStack {
572
512
  * limited to soft delete.
573
513
  */
574
514
  async delete(id, opts = {}) {
575
- const { referencedFileIds } = await this.deleteAndReturn(id, opts);
576
- return { referencedFileIds };
577
- }
578
- /**
579
- * delete(), plus the record it acted on. Gated identically — the
580
- * existence/visibility check that produces a 404 ahead of the 403
581
- * permission gate still runs via requireDeletable() before either kind
582
- * of delete, so this changes what a successful call reports, not the
583
- * disclosure ordering a refused one follows.
584
- */
585
- async deleteAndReturn(id, opts = {}) {
586
- const record = await this.requireDeletable(id, opts);
515
+ const record = await this.requireDeletable(id, opts, this.#authority.snapshot());
587
516
  if (opts.purge && !this.#authority.ownerActingAlone) {
588
517
  throw new StackPermissionError('Purge is owner-only');
589
518
  }
590
- if (!opts[WRITE_EXPECTATION])
591
- return this.stack.deleteAndReturn(id, { ...opts, ...this.actor });
592
519
  // Checked against the read above, which the delete is pinned to.
593
520
  // Forwarded, it would cost a purge the read `Stack` otherwise skips.
594
- const { [WRITE_EXPECTATION]: _checked, ...rest } = opts;
595
- return this.stack.deleteAndReturn(id, {
521
+ const { [WRITE_EXPECTATION]: expectation, ...rest } = opts;
522
+ const result = await this.stack.delete(id, {
596
523
  ...rest,
597
- ifVersion: opts.ifVersion ?? record.version,
524
+ ...(expectation && { ifVersion: opts.ifVersion ?? record.version }),
598
525
  ...this.actor,
599
526
  });
527
+ // A soft delete's record reaches the caller as every scoped read
528
+ // presents one; a purge is the owner's, and reports what it destroyed.
529
+ // See docs/spec/versioning.md § The tombstone is literal.
530
+ return opts.purge || !result.record
531
+ ? result
532
+ : { ...result, record: presentDeleted(result.record) };
600
533
  }
601
534
  /**
602
535
  * Reverse a soft delete. Gated the same as delete() — undelete is the
@@ -604,7 +537,7 @@ export class ScopedStack {
604
537
  * would be backwards. Idempotent, per Stack.undelete().
605
538
  */
606
539
  async undelete(id, opts = {}) {
607
- await this.requireDeletable(id, opts);
540
+ await this.requireDeletable(id, opts, this.#authority.snapshot());
608
541
  return this.stack.undelete(id, { ...opts, ...this.actor });
609
542
  }
610
543
  /**
@@ -615,7 +548,7 @@ export class ScopedStack {
615
548
  * stop. See docs/spec/versioning.md § History access.
616
549
  */
617
550
  async getVersions(id, query = {}) {
618
- await this.requireUpdatable(id, { mutating: false });
551
+ await this.requireUpdatable(id, { mutating: false }, this.#authority.snapshot());
619
552
  return this.stack.getVersions(id, query);
620
553
  }
621
554
  /**
@@ -624,13 +557,13 @@ export class ScopedStack {
624
557
  * See docs/spec/journal.md § Reading it.
625
558
  */
626
559
  async getJournal(id, query = {}) {
627
- const record = await this.requireUpdatable(id, { mutating: false });
560
+ const record = await this.requireUpdatable(id, { mutating: false }, this.#authority.snapshot());
628
561
  const entries = await this.stack.getJournal(id, query);
629
562
  return this.#authority.canReshare(record) ? entries : entries.map(withoutAuthorityChanges);
630
563
  }
631
564
  /** See getVersions() — the same mutate-surface gate. */
632
565
  async getVersion(id, version) {
633
- await this.requireUpdatable(id, { mutating: false });
566
+ await this.requireUpdatable(id, { mutating: false }, this.#authority.snapshot());
634
567
  return this.stack.getVersion(id, version);
635
568
  }
636
569
  /**
@@ -641,15 +574,15 @@ export class ScopedStack {
641
574
  * restore would widen. See docs/spec/versioning.md § Restore semantics.
642
575
  */
643
576
  async restoreVersion(id, version, opts = {}) {
644
- const record = await this.requireUpdatable(id, { expect: opts });
577
+ const snapshot = this.#authority.snapshot();
578
+ const record = await this.requireUpdatable(id, { expect: opts }, snapshot);
645
579
  if (!this.#authority.ownerActingAlone) {
646
580
  const target = await this.stack.getVersion(id, version);
647
581
  if (target) {
648
- // A rollback that would move a card's binding is the same trust
649
- // decision a content patch reserves to the owner, reached by another route.
650
- this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
651
- record.content[field]);
652
- await this.requireFileRefAccess(target.typeId, target.content);
582
+ // A rollback moving a reserved value is the same trust decision a
583
+ // content patch reserves to the owner, reached by another route.
584
+ this.requireOwnerFields(record.typeId, record.content, target.content);
585
+ await this.requireFileRefAccess(target.typeId, target.content, snapshot);
653
586
  // Content's file refs are the only reference a restore can put
654
587
  // back: containment and associations are not on a snapshot at all,
655
588
  // so a restore never introduces either — see
@@ -665,13 +598,15 @@ export class ScopedStack {
665
598
  * See docs/spec/access-control.md § What a grant covers.
666
599
  */
667
600
  async commitMigration(id, toTypeId, content, opts = {}) {
668
- if (!this.#authority.ownerActingAlone)
669
- await this.requireInstallMigration(id, toTypeId);
670
- // The new content is a fresh set of file references, so an app is held
671
- // to the gate create() applies; otherwise a migration could point one of
672
- // its records at any attachment and read the bytes through it.
673
- if (!this.#authority.ownerActingAlone)
674
- await this.requireFileRefAccess(toTypeId, content);
601
+ if (!this.#authority.ownerActingAlone) {
602
+ const snapshot = this.#authority.snapshot();
603
+ const record = await this.requireInstallMigration(id, toTypeId, snapshot);
604
+ this.requireOwnerFields(toTypeId, record.content, content);
605
+ // The new content is a fresh set of file references, so an app is held
606
+ // to the gate create() applies; otherwise a migration could point one
607
+ // of its records at any attachment and read the bytes through it.
608
+ await this.requireFileRefAccess(toTypeId, content, snapshot);
609
+ }
675
610
  return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
676
611
  }
677
612
  /**
@@ -681,14 +616,15 @@ export class ScopedStack {
681
616
  * owner may migrate a tombstone; an app sees it without content.
682
617
  * See docs/spec/apps.md § Migrating an installed app's types.
683
618
  */
684
- async requireInstallMigration(id, toTypeId) {
619
+ async requireInstallMigration(id, toTypeId, snapshot) {
685
620
  const record = await this.stack.get(id, { includeDeleted: true });
686
- if (!record || !(await this.#authority.installMayMigrate(record, toTypeId))) {
621
+ if (!record || !(await this.#authority.installMayMigrate(record, toTypeId, snapshot))) {
687
622
  throw new StackPermissionError('Only the stack owner, or an installed app within the type families it defines, may commit a migration');
688
623
  }
689
624
  if (record.deletedAt) {
690
625
  throw new StackConflictError(`Record "${id}" is soft-deleted; undelete it before migrating it.`);
691
626
  }
627
+ return record;
692
628
  }
693
629
  /**
694
630
  * Store bytes and create an _attachment@1 metadata record (create grant
@@ -705,7 +641,7 @@ export class ScopedStack {
705
641
  if (!principal) {
706
642
  throw new StackPermissionError('Anonymous requesters cannot upload attachments');
707
643
  }
708
- await this.requireCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`);
644
+ await this.requireCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`, this.#authority.snapshot());
709
645
  await this.requireAppIdMatchesPrincipal(appId);
710
646
  assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
711
647
  const fileId = await this.adapter.putBlob(data);
@@ -717,7 +653,7 @@ export class ScopedStack {
717
653
  * predicate as the reference-creation gate (canAccessFile).
718
654
  */
719
655
  async getAttachment(fileId) {
720
- if (!(await this.#authority.canAccessFile(fileId))) {
656
+ if (!(await this.#authority.canAccessFile(fileId, this.#authority.snapshot()))) {
721
657
  throw new StackPermissionError(`Cannot read file "${fileId}": it does not exist or you cannot read it`);
722
658
  }
723
659
  return this.stack.getAttachment(fileId);
@@ -740,37 +676,20 @@ export class ScopedStack {
740
676
  }
741
677
  /**
742
678
  * Observe the changes this request may read, decided per event by the
743
- * same canRead() that get() and query() use. An unreadable record yields
679
+ * same read reach that get() and query() ask. An unreadable record yields
744
680
  * no event at all, since a change's existence is itself a disclosure.
745
681
  * See docs/spec/events.md § Permission scoping.
746
682
  */
747
683
  async subscribe(handler, opts = {}) {
748
684
  this.assertStackOpen();
749
- if (this.relaysChanges) {
750
- throw new RelayScopeError('This stack relays changes from elsewhere, and a scoped view cannot narrow that feed: ' +
751
- 'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
752
- 'no record to re-check. Subscribe with a session-scoped stack instead.');
753
- }
754
- // A relaying stack was already refused above, so relaysChanges is
755
- // always false here — `since` never has a cursor to mean anything by.
685
+ // A scoped view exists only over a stack that relays nothing, so
686
+ // `since` never has a cursor to mean anything by.
756
687
  assertSinceUsable(opts.since, false);
757
688
  assertValidBaseIdFilter(opts.filter);
758
- if (opts.includeUnlisted && !this.#authority.ownerActingAlone) {
689
+ if (opts.filter?.includeUnlisted && !this.#authority.ownerActingAlone) {
759
690
  throw new StackPermissionError('includeUnlisted is owner-only');
760
691
  }
761
- return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
762
- }
763
- /**
764
- * canRead for one event, through the subscription's own cache. Grants
765
- * are prefetched once and refilled after an invalidation rather than
766
- * re-queried per event, which would be a `_grant` scan for every change
767
- * the stack makes.
768
- */
769
- async canReadCached(record, cache) {
770
- const grants = this.#authority.identified
771
- ? await cache.grants(() => this.#authority.loadGrants())
772
- : undefined;
773
- return this.#authority.canRead(record, grants, cache.roles);
692
+ return this.changes.add(new ScopedSubscription(this.#authority.snapshot(), (record, snapshot) => this.#authority.canRead(record, snapshot), handler, opts));
774
693
  }
775
694
  }
776
695
  //# sourceMappingURL=scoped-stack.js.map