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