@haverstack/core 0.29.0 → 0.31.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 (74) hide show
  1. package/dist/adapter-entry.d.ts +2 -2
  2. package/dist/adapter-entry.d.ts.map +1 -1
  3. package/dist/adapter-entry.js +1 -1
  4. package/dist/adapter-entry.js.map +1 -1
  5. package/dist/changes.d.ts +14 -0
  6. package/dist/changes.d.ts.map +1 -1
  7. package/dist/changes.js +35 -0
  8. package/dist/changes.js.map +1 -1
  9. package/dist/errors.d.ts +191 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +219 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/grants.d.ts +84 -0
  14. package/dist/grants.d.ts.map +1 -0
  15. package/dist/grants.js +157 -0
  16. package/dist/grants.js.map +1 -0
  17. package/dist/id.d.ts +7 -5
  18. package/dist/id.d.ts.map +1 -1
  19. package/dist/id.js +17 -15
  20. package/dist/id.js.map +1 -1
  21. package/dist/identity-bindings.d.ts +13 -0
  22. package/dist/identity-bindings.d.ts.map +1 -0
  23. package/dist/identity-bindings.js +42 -0
  24. package/dist/identity-bindings.js.map +1 -0
  25. package/dist/index.d.ts +7 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/limits.d.ts +23 -0
  30. package/dist/limits.d.ts.map +1 -0
  31. package/dist/limits.js +35 -0
  32. package/dist/limits.js.map +1 -0
  33. package/dist/query-validation.d.ts +83 -0
  34. package/dist/query-validation.d.ts.map +1 -0
  35. package/dist/query-validation.js +228 -0
  36. package/dist/query-validation.js.map +1 -0
  37. package/dist/record-changes.d.ts +89 -0
  38. package/dist/record-changes.d.ts.map +1 -0
  39. package/dist/record-changes.js +201 -0
  40. package/dist/record-changes.js.map +1 -0
  41. package/dist/record-id.d.ts +70 -0
  42. package/dist/record-id.d.ts.map +1 -0
  43. package/dist/record-id.js +127 -0
  44. package/dist/record-id.js.map +1 -0
  45. package/dist/scoped-stack.d.ts +468 -0
  46. package/dist/scoped-stack.d.ts.map +1 -0
  47. package/dist/scoped-stack.js +1173 -0
  48. package/dist/scoped-stack.js.map +1 -0
  49. package/dist/stack-reads.d.ts +38 -0
  50. package/dist/stack-reads.d.ts.map +1 -0
  51. package/dist/stack-reads.js +79 -0
  52. package/dist/stack-reads.js.map +1 -0
  53. package/dist/stack.d.ts +37 -643
  54. package/dist/stack.d.ts.map +1 -1
  55. package/dist/stack.js +102 -2254
  56. package/dist/stack.js.map +1 -1
  57. package/dist/testing.d.ts.map +1 -1
  58. package/dist/testing.js +3 -1
  59. package/dist/testing.js.map +1 -1
  60. package/dist/types.d.ts +16 -2
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/types.js +8 -0
  63. package/dist/types.js.map +1 -1
  64. package/dist/wire-entry.d.ts +3 -2
  65. package/dist/wire-entry.d.ts.map +1 -1
  66. package/dist/wire-entry.js +6 -2
  67. package/dist/wire-entry.js.map +1 -1
  68. package/dist/wire-record.js +1 -1
  69. package/dist/wire-record.js.map +1 -1
  70. package/dist/wire-request.d.ts +14 -0
  71. package/dist/wire-request.d.ts.map +1 -1
  72. package/dist/wire-request.js +19 -2
  73. package/dist/wire-request.js.map +1 -1
  74. package/package.json +1 -1
@@ -0,0 +1,1173 @@
1
+ /**
2
+ * ScopedStack — the permission-enforcing view
3
+ * -------------------------------------------------------
4
+ * A `Stack` answers every question; a `ScopedStack` answers only the ones a
5
+ * particular requester is entitled to. It wraps a Stack rather than an
6
+ * adapter, so every invariant the Stack layer holds is already in force by
7
+ * the time a check here runs, and the gates below only ever narrow.
8
+ *
9
+ * Two identities, one rule: **the principal governs authority, the subject
10
+ * governs attribution.** Both are DIDs and both are often equal, which is
11
+ * what makes them easy to confuse — so the split is stated on every gate
12
+ * that depends on it.
13
+ *
14
+ * Also holds the feed side of the same rule: a subscription's deliveries
15
+ * run through the same canRead() a get() answers with, so a feed can never
16
+ * disagree with a read about what a session sees.
17
+ *
18
+ * See docs/spec/access-control.md and docs/spec/events.md § Permission scoping.
19
+ */
20
+ import { baseIdOf } from './schema.js';
21
+ import { validatePatchValues } from './validate.js';
22
+ import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone } from './access.js';
23
+ import { ChangeEmitter, Subscription, matchesFilter, passesUnlistedBoundary, assertSinceUsable, } from './changes.js';
24
+ import { SYSTEM_TYPES } from './types.js';
25
+ import { StackConflictError, StackError, StackNotFoundError, StackPermissionError, StackRelayScopeError, StackValidationError, } from './errors.js';
26
+ import { assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, } from './query-validation.js';
27
+ import { grantConveys, grantCoversGrantee, loadGrantRecords, UNGRANTABLE_SYSTEM_TYPES, } from './grants.js';
28
+ import { bindingFieldsOf } from './identity-bindings.js';
29
+ import { assertAttachmentSize } from './limits.js';
30
+ import { validateIdTimestampSkew, validateRecordId } from './record-id.js';
31
+ import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, findFirstMatch, lookupEntityByDid, queryAllPages, } from './stack-reads.js';
32
+ import { associationEqual, isGroupRecord, presentDeleted, stripVersionPermissions, assertNonEmptyChangeSet, } from './record-changes.js';
33
+ /**
34
+ * The authority lookups canRead needs, held for the life of one
35
+ * subscription. A subscription is long-lived where a query is not, so the
36
+ * cache is only safe because every write that can change canRead's answer
37
+ * arrives as an event that drops it — see ScopedSubscription.
38
+ */
39
+ class FeedAuthorityCache {
40
+ grantRecords = null;
41
+ /**
42
+ * Bumped by every invalidation, so a load that was already in flight can
43
+ * tell that its result is stale before seating it.
44
+ */
45
+ generation = 0;
46
+ /** Roster roles, memoized per group, as ScopedStack.query() does per query. */
47
+ roles = new Map();
48
+ /** Every `_grant` record, refilled through `load` after an invalidation. */
49
+ async grants(load) {
50
+ if (this.grantRecords !== null)
51
+ return this.grantRecords;
52
+ const generation = this.generation;
53
+ const loaded = await load();
54
+ // An invalidation during the load already dropped the set these
55
+ // replace, so seating them would outlive the write that expired them
56
+ // and no later event would drop them again. The event being decided
57
+ // precedes that write, so it is still decided on what was loaded.
58
+ if (this.generation === generation)
59
+ this.grantRecords = loaded;
60
+ return loaded;
61
+ }
62
+ invalidateFor(typeFamily) {
63
+ if (typeFamily === SYSTEM_TYPES.GRANT) {
64
+ this.grantRecords = null;
65
+ this.generation++;
66
+ }
67
+ if (typeFamily === SYSTEM_TYPES.GROUP)
68
+ this.roles = new Map();
69
+ }
70
+ }
71
+ /**
72
+ * A ScopedStack's delivery: canRead per event, with the grant and roster
73
+ * lookups it needs cached for the life of the subscription and dropped the
74
+ * moment anything that feeds them changes.
75
+ *
76
+ * Two properties do the work, and neither is optional:
77
+ *
78
+ * **Deliveries are serialized.** The permission decision is asynchronous,
79
+ * so without a queue two changes to one record could resolve out of order
80
+ * and be delivered newest-first — breaking the one ordering guarantee the
81
+ * feed makes. Every emission is appended to a chain instead.
82
+ *
83
+ * **The filter fails closed.** A permission check that throws drops the
84
+ * event and reports the error; it never delivers on the assumption that a
85
+ * failed check would have passed.
86
+ *
87
+ * See docs/spec/events.md § Permission scoping.
88
+ */
89
+ class ScopedSubscription extends Subscription {
90
+ canRead;
91
+ cache = new FeedAuthorityCache();
92
+ /** Tail of the delivery chain — see the class comment. */
93
+ queue = Promise.resolve();
94
+ constructor(canRead, handler, opts) {
95
+ super(handler, opts);
96
+ this.canRead = canRead;
97
+ }
98
+ accept(emission) {
99
+ // Invalidation reads every emission, including the ones this
100
+ // subscriber may not see: a revocation the subscriber cannot read is
101
+ // exactly the one that must still expire its cache.
102
+ this.invalidateFor(emission);
103
+ if (!matchesFilter(emission, this.opts.filter))
104
+ return;
105
+ this.queue = this.queue.then(() => this.filterAndDeliver(emission));
106
+ }
107
+ /**
108
+ * A cached grant set outlives the write that revokes it unless something
109
+ * drops it. Both writes that can change canRead's answer — a `_grant`
110
+ * record, or a `_group` roster — arrive here as ordinary events, which
111
+ * is what makes the cache safe to hold at all. A future authority change
112
+ * that did not emit would silently strand it.
113
+ */
114
+ invalidateFor(emission) {
115
+ this.cache.invalidateFor(baseIdOf(emission.change.typeId));
116
+ }
117
+ async filterAndDeliver(emission) {
118
+ if (this.isClosed)
119
+ return;
120
+ if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted))
121
+ return;
122
+ try {
123
+ if (!(await this.canRead(emission.record, this.cache)))
124
+ return;
125
+ }
126
+ catch (err) {
127
+ // Fail closed: an undecided permission question is not a yes.
128
+ this.reportError(err);
129
+ return;
130
+ }
131
+ // After the permission decision, which needs the whole record: a frame
132
+ // carrying a deleted record's body would be a read channel around the
133
+ // tombstone. See docs/spec/events.md § Soft-deleted records reach the
134
+ // feed as tombstones.
135
+ this.deliver(this.project(emission));
136
+ }
137
+ project(emission) {
138
+ const record = presentDeleted(emission.record);
139
+ return record === emission.record ? emission : { ...emission, record };
140
+ }
141
+ }
142
+ /**
143
+ * A permission-enforcing view of a Stack for a single (principal, subject)
144
+ * pair, obtained via `stack.asEntity(entityId)`. A record the request
145
+ * cannot read answers exactly as a missing one does — null on reads,
146
+ * StackNotFoundError on the verbs that name one — so only a requester who
147
+ * could have read it is told a refusal was about access.
148
+ * See docs/spec/access-control.md § Errors and information exposure.
149
+ *
150
+ * Two identities, one rule: **the principal governs authority, the subject
151
+ * governs attribution.** Grant lookup and the privilege-bearing gates that
152
+ * no grant reaches (resharing, group management, hard delete, widening
153
+ * access at create time) key on `principalEntityId`; authorship, `-own`
154
+ * matching, record-level permission resolution, and "files I uploaded"
155
+ * lookups key on `subjectEntityId`.
156
+ *
157
+ * Unconditional owner access follows that same split rather than one
158
+ * identity: it answers *what data is reachable* for the subject (an owner
159
+ * subject resolves past every permission check) and *who may exercise a
160
+ * privileged verb* for the principal (an owner app is not bounded by
161
+ * grants). Under delegation both halves apply, and a mistake on the
162
+ * authority side is an escalation rather than a preference.
163
+ */
164
+ export class ScopedStack {
165
+ stack;
166
+ principalEntityId;
167
+ subjectEntityId;
168
+ idTimestampSkewMs;
169
+ adapter;
170
+ changes;
171
+ constructor(stack, principalEntityId, subjectEntityId, idTimestampSkewMs,
172
+ // Bytes-storage primitive for putAttachment(). Held directly because
173
+ // ScopedStack always composes bytes + its own create() — the record
174
+ // must carry the subject's entityId and the principal behind it,
175
+ // neither of which the adapter-level atomic capability takes.
176
+ adapter,
177
+ // The stack's own stream, filtered by subscribe(). Held here rather
178
+ // than reached through a method on Stack so the unfiltered stream
179
+ // stays inside this module.
180
+ changes) {
181
+ this.stack = stack;
182
+ this.principalEntityId = principalEntityId;
183
+ this.subjectEntityId = subjectEntityId;
184
+ this.idTimestampSkewMs = idTimestampSkewMs;
185
+ this.adapter = adapter;
186
+ this.changes = changes;
187
+ }
188
+ get features() {
189
+ return this.stack.features;
190
+ }
191
+ resolveRecord = (id) => this.stack.get(id);
192
+ /** Every `_grant` Record — see loadGrantRecords(). */
193
+ loadGrants = () => loadGrantRecords((q) => this.stack.query(q));
194
+ /** Whether a delegated app is acting for someone other than itself. */
195
+ get delegated() {
196
+ return this.subjectEntityId !== this.principalEntityId;
197
+ }
198
+ /**
199
+ * Who this request is, for stamping onto whatever it mutates. Attribution
200
+ * follows record-level authorship: the subject is the actor, and the
201
+ * principal is named beside it only when the two differ.
202
+ * See docs/spec/data-model.md § Authorship and attribution.
203
+ */
204
+ get actor() {
205
+ // Both keys are always present, so spreading this last overrides
206
+ // anything a caller passed: a scoped requester names itself by making
207
+ // the request, never by describing itself in the options.
208
+ return {
209
+ updatedBy: this.subjectEntityId ?? undefined,
210
+ updatedVia: this.delegated ? (this.principalEntityId ?? undefined) : undefined,
211
+ };
212
+ }
213
+ /**
214
+ * Whether unconditional owner authority applies — the owner acting as
215
+ * itself. The verbs that rest on it are irreversible or disclose the
216
+ * sharing graph, so delegation never carries one to a subject, whichever
217
+ * side the owner is on. Shared with the predicate a server applies to a
218
+ * session, which decides the same tier one step earlier. See
219
+ * docs/spec/access-control.md § Delegation: principal and subject.
220
+ */
221
+ get ownerActingAlone() {
222
+ return isOwnerActingAlone({ principalId: this.principalEntityId, subjectId: this.subjectEntityId }, this.stack.ownerEntityId);
223
+ }
224
+ /**
225
+ * Refuse anything but the owner acting as itself. The verbs resting on
226
+ * this tier are irreversible or disclose the sharing graph, so delegation
227
+ * never carries one to a subject — see ownerActingAlone.
228
+ */
229
+ requireOwnerActingAlone(message) {
230
+ if (!this.ownerActingAlone)
231
+ throw new StackPermissionError(message);
232
+ }
233
+ checkRead(record) {
234
+ return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'read', this.resolveRecord);
235
+ }
236
+ checkWrite(record) {
237
+ return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'write', this.resolveRecord);
238
+ }
239
+ /**
240
+ * Whether `grantee` holds a _grant covering one of `actions` for the
241
+ * type's family (grants match by baseId, so a version bump never orphans
242
+ * one). -own actions additionally require record.entityId === grantee,
243
+ * unless `matchOwn` is false — on the principal side of a delegated
244
+ * request the suffix is read as the bare verb, since which records are
245
+ * reachable is the subject's business. `allowDefault` decides whether a
246
+ * grant naming nobody counts. Anonymous grantees always return false.
247
+ *
248
+ * Reached only through subjectAllows()/principalAllows(), which fix those
249
+ * two flags per side of the intersection. Call one of those instead.
250
+ * See docs/spec/access-control.md § Type-level grants.
251
+ */
252
+ async hasGrant(typeId, actions, opts) {
253
+ const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
254
+ if (!grantee)
255
+ return false;
256
+ // Absent when the caller has no operation-scoped map to share — one
257
+ // call's worth of memoization, which is still every grant record in
258
+ // this loop naming the same group.
259
+ const groupRoles = opts.groupRoles ?? new Map();
260
+ const familyId = baseIdOf(typeId);
261
+ // grant() refuses to write these, but a _grant record is an ordinary
262
+ // Record: an unscoped Stack, an import, or a server mapping a request
263
+ // body onto Stack can mint one anyway. Refusing at the point of use is
264
+ // what makes the rule hold regardless of how the record got there.
265
+ if (UNGRANTABLE_SYSTEM_TYPES.has(familyId))
266
+ return false;
267
+ let grantRecords;
268
+ if (prefetchedGrants !== undefined) {
269
+ grantRecords = prefetchedGrants;
270
+ }
271
+ else {
272
+ // No content-field prefilter: a stored grant's typeId may be a bare
273
+ // baseId or versioned, so exact matching would wrongly exclude family
274
+ // versions. Cursor-walked to see grants past page one.
275
+ grantRecords = await this.loadGrants();
276
+ }
277
+ for (const r of grantRecords) {
278
+ const c = r.content;
279
+ if (baseIdOf(c.typeId) !== familyId)
280
+ continue;
281
+ const covers = await grantCoversGrantee(c, grantee, {
282
+ allowDefault,
283
+ allowGroup,
284
+ groupRoles,
285
+ resolveRecord: this.resolveRecord,
286
+ });
287
+ if (!covers)
288
+ continue;
289
+ const matches = actions.some((action) => {
290
+ if (!grantConveys(c.actions, action))
291
+ return false;
292
+ if (matchOwn && action.endsWith('-own'))
293
+ return record?.entityId === grantee;
294
+ return true;
295
+ });
296
+ if (matches)
297
+ return true;
298
+ }
299
+ return false;
300
+ }
301
+ /**
302
+ * The principal half of a delegated request's authority: does the app
303
+ * hold any grant permitting these verbs on this type at all. Bounds what
304
+ * the subject's own authority can reach through it, so a powerful app
305
+ * can never lend its reach to a weaker subject — nor the reverse.
306
+ * Vacuously true when there's no delegation, where the principal and
307
+ * subject checks would be the same question asked twice.
308
+ *
309
+ * Default grants don't count here. "Any authenticated entity" is about
310
+ * people who turn up, not software the owner installed — an app reaches
311
+ * only the types named to it, which is the whole of what containment
312
+ * promises.
313
+ *
314
+ * Group-targeted grants don't count here either, one step removed: a
315
+ * roster is editable by any of the group's admins, so authority reaching
316
+ * a principal through one would let someone other than the owner name an
317
+ * app to a type. See docs/spec/access-control.md § Type-level grants.
318
+ */
319
+ principalAllows(typeId, actions, prefetchedGrants) {
320
+ if (!this.delegated)
321
+ return Promise.resolve(true);
322
+ if (this.principalEntityId === this.stack.ownerEntityId)
323
+ return Promise.resolve(true);
324
+ return this.hasGrant(typeId, actions, {
325
+ grantee: this.principalEntityId,
326
+ prefetchedGrants,
327
+ matchOwn: false,
328
+ allowDefault: false,
329
+ allowGroup: false,
330
+ });
331
+ }
332
+ /**
333
+ * The subject half: which records are reachable, answered with `-own`
334
+ * matching and default grants both in force — the ordinary reading of a
335
+ * grant, since the subject is the entity a grant is written about.
336
+ *
337
+ * Paired with principalAllows() so that the two halves of the
338
+ * intersection are the only callers of hasGrant(): its flags differ per
339
+ * side and mean nothing on their own, so no call site sets them by hand.
340
+ */
341
+ subjectAllows(typeId, actions, opts = {}) {
342
+ return this.hasGrant(typeId, actions, {
343
+ grantee: this.subjectEntityId,
344
+ record: opts.record,
345
+ prefetchedGrants: opts.prefetchedGrants,
346
+ groupRoles: opts.groupRoles,
347
+ });
348
+ }
349
+ /**
350
+ * How to refuse a record this request addressed by ID. A requester who
351
+ * can read the record is told it exists and the verb was refused;
352
+ * everyone else is told what a missing ID is told, so no one learns an ID
353
+ * is live who could not have learned it by reading. `message` therefore
354
+ * only ever reaches someone holding the record already.
355
+ * See docs/spec/access-control.md § Errors and information exposure.
356
+ */
357
+ async denialFor(record, message) {
358
+ // Prefetched here rather than threaded down from the gate: a write
359
+ // carried by a record-level permission settles without reading a grant
360
+ // at all, and that path must not pay for this one. Both halves of
361
+ // canRead share the one scan.
362
+ const grants = this.principalEntityId || this.subjectEntityId ? await this.loadGrants() : undefined;
363
+ if (await this.canRead(record, grants))
364
+ return new StackPermissionError(message);
365
+ return new StackNotFoundError(`Record not found: "${record.id}"`);
366
+ }
367
+ async canRead(record, prefetchedGrants, groupRoles) {
368
+ const reachable = (await this.checkRead(record)) ||
369
+ (await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
370
+ record,
371
+ prefetchedGrants,
372
+ groupRoles,
373
+ }));
374
+ if (!reachable)
375
+ return false;
376
+ return this.principalAllows(record.typeId, ['read-own', 'read-any'], prefetchedGrants);
377
+ }
378
+ async checkCreateGrant(typeId) {
379
+ if (this.ownerActingAlone)
380
+ return true;
381
+ const reachable = this.subjectEntityId === this.stack.ownerEntityId ||
382
+ (await this.subjectAllows(typeId, ['create']));
383
+ if (!reachable)
384
+ return false;
385
+ return this.principalAllows(typeId, ['create']);
386
+ }
387
+ /**
388
+ * `_group` records are managed, not merely written: only the owner or an
389
+ * `admin` roster holder may mutate them — ordinary write permissions and
390
+ * grants don't apply. Asked of both identities under delegation, like
391
+ * a reshare. See docs/spec/identity.md § Group.
392
+ */
393
+ isGroupManager(record) {
394
+ if (!this.managesGroup(this.principalEntityId, record))
395
+ return false;
396
+ return !this.delegated || this.managesGroup(this.subjectEntityId, record);
397
+ }
398
+ /** Whether one identity, on its own, manages `record` — see isGroupManager(). */
399
+ managesGroup(entityId, record) {
400
+ if (!entityId)
401
+ return false;
402
+ if (entityId === this.stack.ownerEntityId)
403
+ return true;
404
+ return groupRoleFromAssociations(record.associations, entityId) === 'admin';
405
+ }
406
+ /**
407
+ * Fetch a record the subject can reach and the principal holds `update` on
408
+ * (via permissions or an update grant), or throw. `mutating: false` marks
409
+ * the history readers, which borrow this gate without changing anything —
410
+ * the one way through it a `_grant` Record stays open to.
411
+ */
412
+ async requireUpdatable(id, opts = {}) {
413
+ const mutating = opts.mutating ?? true;
414
+ // The `_grant` write fence applies to mutating callers only: reading a
415
+ // grant Record's history is not the escalation that fence exists to stop.
416
+ const record = await this.requireVerb(id, ['update-own', 'update-any'], {
417
+ fenceGrantRecord: mutating,
418
+ });
419
+ return this.refuseIfDeleted(record, mutating);
420
+ }
421
+ /**
422
+ * The write gate both requireUpdatable() and requireDeletable() are: the
423
+ * subject can reach the record (record-level permission or a grant) and
424
+ * the principal holds the same verb, intersected — or, for a `_group`, the
425
+ * stricter management rule that no grant reaches.
426
+ * See docs/spec/access-control.md § Delegation: principal and subject.
427
+ */
428
+ async requireVerb(id, actions, opts) {
429
+ const record = await this.stack.get(id);
430
+ if (!record)
431
+ throw new StackNotFoundError(`Record not found: "${id}"`);
432
+ if (opts.fenceGrantRecord)
433
+ await this.requireOwnerForGrantRecord(record);
434
+ if (isGroupRecord(record)) {
435
+ if (!this.isGroupManager(record))
436
+ throw await this.denialFor(record);
437
+ return record;
438
+ }
439
+ const allowed = ((await this.checkWrite(record)) ||
440
+ (await this.subjectAllows(record.typeId, actions, { record }))) &&
441
+ (await this.principalAllows(record.typeId, actions));
442
+ if (!allowed)
443
+ throw await this.denialFor(record);
444
+ return record;
445
+ }
446
+ /**
447
+ * Refuse a mutation aimed at a soft-deleted Record. Asked only of a
448
+ * mutating caller, so the history readers borrowing this gate still work,
449
+ * and only after the authority decision, so "exists but deleted" is never
450
+ * a probe a stranger can run. See docs/spec/versioning.md § Mutations are
451
+ * refused, not applied to a tombstone.
452
+ */
453
+ refuseIfDeleted(record, mutating) {
454
+ if (mutating && record.deletedAt) {
455
+ throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
456
+ }
457
+ return record;
458
+ }
459
+ /**
460
+ * Fetch a record the subject can reach and the principal holds `delete` on
461
+ * (via permissions or a delete grant), or throw.
462
+ */
463
+ requireDeletable(id) {
464
+ // No soft-delete refusal: undelete() shares this gate, and a tombstone
465
+ // is exactly what it addresses.
466
+ return this.requireVerb(id, ['delete-own', 'delete-any'], { fenceGrantRecord: true });
467
+ }
468
+ /**
469
+ * Whether this request may reference `recordId` (as a parentId or
470
+ * relationship target). Missing and unreadable both return false —
471
+ * indistinguishable, so this can't probe for a record's existence.
472
+ *
473
+ * The owner acting alone passes without the lookup, which costs nothing:
474
+ * there is no record in their own stack they may not read, so the gate
475
+ * could only ever refuse them for absence — and absence is `Stack`'s to
476
+ * answer, with the conflict that names the problem rather than a
477
+ * refusal standing in for it. The anti-oracle property is unchanged for
478
+ * every other requester.
479
+ * See docs/spec/access-control.md § Reference-creation gating.
480
+ */
481
+ async canReadReferent(recordId) {
482
+ if (this.ownerActingAlone)
483
+ return true;
484
+ const record = await this.stack.get(recordId);
485
+ if (!record)
486
+ return false;
487
+ return this.canRead(record);
488
+ }
489
+ /**
490
+ * Whether this request can read some record referencing `fileId` —
491
+ * shared by canAccessFile() and the non-owner _attachment@1 create()
492
+ * carve-out, which deliberately excludes the uploader clause. Walks
493
+ * every referencing record, short-circuiting on the first readable one.
494
+ * `_attachment@1` records are excluded from matching outright: the
495
+ * carve-out must be satisfied by some *other* record referencing the
496
+ * file, never by the requester's own prior metadata record for it —
497
+ * allowing that would let one successful guess unlock unlimited further
498
+ * metadata records for the same fileId, reintroducing the circularity
499
+ * the carve-out's uploader-clause exclusion closes.
500
+ * See docs/spec/attachments.md § Creating `_attachment@1` records directly.
501
+ */
502
+ async hasReadableReference(fileId) {
503
+ const prefetchedGrants = this.subjectEntityId ? await this.loadGrants() : undefined;
504
+ const groupRoles = new Map();
505
+ const match = await findFirstMatch((q) => this.stack.query(q),
506
+ // Reach, not enumeration: a record readable by ID conveys the file it
507
+ // references. See docs/spec/unlisted.md.
508
+ { filter: { attachmentFileId: fileId, includeUnlisted: true } }, (record) => baseIdOf(record.typeId) !== SYSTEM_TYPES.ATTACHMENT &&
509
+ this.canRead(record, prefetchedGrants, groupRoles));
510
+ return match !== undefined;
511
+ }
512
+ /**
513
+ * Whether this request may reference or download `fileId` — the dual of
514
+ * getAttachment()'s access rule. Nonexistent and inaccessible are
515
+ * indistinguishable (both false), so no confirmation oracle for guessed
516
+ * hashes. See docs/spec/access-control.md § Reference-creation gating.
517
+ */
518
+ async canAccessFile(fileId) {
519
+ if (this.ownerActingAlone)
520
+ return true;
521
+ // Reaching a file through a record this request can read is already
522
+ // fully intersected — canRead() applied the principal's mask against
523
+ // that record's own type, which is the type the reference lives on.
524
+ if (await this.hasReadableReference(fileId))
525
+ return true;
526
+ if (!this.subjectEntityId)
527
+ return false;
528
+ // The remaining paths are authorship facts about the subject, so they
529
+ // decide *which* files match — they are not themselves a grant, and the
530
+ // principal still needs one of its own on the attachment type.
531
+ if (!(await this.principalAllows(`${SYSTEM_TYPES.ATTACHMENT}@1`, ['read-own', 'read-any']))) {
532
+ return false;
533
+ }
534
+ if (this.subjectEntityId === this.stack.ownerEntityId)
535
+ return true;
536
+ return filtersContent(this.stack.features)
537
+ ? (await this.stack.query({
538
+ filter: {
539
+ typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
540
+ entityId: this.subjectEntityId,
541
+ content: { fileId },
542
+ },
543
+ limit: 1,
544
+ })).records.length > 0
545
+ : (await queryAllPages((q) => this.stack.query(q), {
546
+ filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, entityId: this.subjectEntityId },
547
+ })).some((r) => r.content.fileId === fileId);
548
+ }
549
+ /** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
550
+ async fileRefFieldNames(typeId) {
551
+ const type = await this.stack.getType(typeId);
552
+ if (!type)
553
+ return [];
554
+ return Object.entries(type.schema)
555
+ .filter(([, def]) => def.kind === 'file-ref')
556
+ .map(([field]) => field);
557
+ }
558
+ /**
559
+ * Gates file-ref content fields on file access, mirroring the
560
+ * attachment-association gate — a file-ref field conveys attachment
561
+ * access exactly like an `attachment` association. Only fields present
562
+ * in `content` are checked (a content patch is a merge patch; untouched fields
563
+ * carry no new reference).
564
+ */
565
+ async requireFileRefAccess(typeId, content) {
566
+ for (const field of await this.fileRefFieldNames(typeId)) {
567
+ const value = content[field];
568
+ if (typeof value !== 'string')
569
+ continue;
570
+ if (!(await this.canAccessFile(value)))
571
+ throw new StackPermissionError();
572
+ }
573
+ }
574
+ /**
575
+ * Reference-creation gate for one association: `attachment` requires
576
+ * file access, and a `relationship` naming a record in this stack
577
+ * requires read access to it. `tag` is unchecked, and `_group` roster
578
+ * associations are gated by the stricter isGroupManager() instead.
579
+ *
580
+ * The other target arms are ungated because the gate's purpose —
581
+ * refusing a reference that would convey access to, or confirm the
582
+ * existence of, an unreadable record — has nothing to bite on: core
583
+ * never resolves them, so no access flows through one.
584
+ * See docs/spec/access-control.md § Reference-creation gating.
585
+ */
586
+ async requireAssociationAccess(typeId, association) {
587
+ if (association.kind === 'attachment') {
588
+ if (!(await this.canAccessFile(association.fileId)))
589
+ throw new StackPermissionError();
590
+ }
591
+ else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
592
+ const { target } = association;
593
+ // `stackUrl` is tested for a value, not for presence: absent and
594
+ // empty are one target — storage, targetEqual() and the filter all
595
+ // read them as this stack — so a check on presence alone would
596
+ // leave one spelling of a local Record ungated.
597
+ if (target.scope !== 'record' || target.stackUrl)
598
+ return;
599
+ if (!(await this.canReadReferent(target.recordId)))
600
+ throw new StackPermissionError();
601
+ }
602
+ }
603
+ /**
604
+ * Create a record on behalf of the subject: create grant required,
605
+ * anonymous denied, entityId set to the subject, client IDs skew-checked,
606
+ * reference-creating options gated, and non-owner `_attachment@1`
607
+ * creation refused save one carve-out. A scoped create always stamps
608
+ * authorship — an absent entityId means an unscoped `Stack` wrote it.
609
+ * `createdAt`/`updatedAt` are refused to everyone but the owner acting
610
+ * alone — see the guard below and docs/spec/data-model.md § Record IDs.
611
+ * See also docs/spec/access-control.md and docs/spec/attachments.md.
612
+ */
613
+ async create(typeId, content, opts = {}) {
614
+ const principal = this.principalEntityId;
615
+ if (!principal)
616
+ throw new StackPermissionError('Anonymous requesters cannot create records');
617
+ // createdAt/updatedAt let a caller backdate a record's clock fields —
618
+ // and, without `id` also supplied, its sort position too. Refused to
619
+ // everyone but the owner acting alone (undelegated, authenticated as
620
+ // themselves): a grantee is exactly the untrusted actor the `id`
621
+ // skew check below already exists to stop from forging a sort
622
+ // position, and a delegated app acting for the owner inherits none of
623
+ // the owner's extra trust — same reasoning as mayGrantAccess() below.
624
+ // Refused rather than silently dropped, so an app never believes it
625
+ // published something it didn't — asked value-wise, since an
626
+ // `undefined` carries no date and Stack.create() reads it as absent.
627
+ // This is also the enforcement a server built on ScopedStack inherits
628
+ // for `POST /records`: an owner-authenticated request may carry both
629
+ // fields, anyone else's has them ignored.
630
+ if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) && !this.ownerActingAlone) {
631
+ 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.');
632
+ }
633
+ if (!(await this.checkCreateGrant(typeId))) {
634
+ throw new StackPermissionError(`No create grant for type "${typeId}"`);
635
+ }
636
+ // The exemption is the owner's own, so delegation doesn't carry it: an
637
+ // owner principal acting for someone else would otherwise let that
638
+ // subject name any fileId and reach the bytes through the uploader
639
+ // clause, which matches on the subject this create stamps.
640
+ if (!this.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
641
+ const fileId = content.fileId;
642
+ if (typeof fileId !== 'string' || !(await this.hasReadableReference(fileId))) {
643
+ throw new StackPermissionError();
644
+ }
645
+ }
646
+ if (opts.permissions?.length && !this.mayGrantAccess()) {
647
+ throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
648
+ }
649
+ if (opts.unlisted && !this.mayGrantAccess()) {
650
+ throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
651
+ }
652
+ this.requireOwnerForOwnerDid(typeId, content.did);
653
+ await this.requireAppIdMatchesPrincipal(opts.appId);
654
+ if (opts.id !== undefined) {
655
+ validateRecordId(opts.id);
656
+ // Skipped when createdAt is also supplied: only the owner reaches
657
+ // here with that combination (checked above), and Stack.create()
658
+ // below checks the id against createdAt instead of "now" — the
659
+ // check here exists for a live grantee write, and a backdated
660
+ // owner create is deliberately not one. See
661
+ // docs/spec/data-model.md § Record IDs.
662
+ if (opts.createdAt === undefined) {
663
+ validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
664
+ }
665
+ }
666
+ if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
667
+ throw new StackPermissionError();
668
+ }
669
+ for (const assoc of opts.associations ?? []) {
670
+ await this.requireAssociationAccess(typeId, assoc);
671
+ }
672
+ await this.requireFileRefAccess(typeId, content);
673
+ return this.stack.create(typeId, content, {
674
+ ...opts,
675
+ entityId: this.subjectEntityId ?? undefined,
676
+ principalId: this.delegated ? principal : undefined,
677
+ });
678
+ }
679
+ /**
680
+ * Whether this request may decide who else reaches a record — the rule
681
+ * a reshare enforces, asked at create time too so the reach it
682
+ * withholds can't be taken one step earlier while authoring. A delegated
683
+ * app is denied it: widening access is the one thing containment most
684
+ * needs to hold. Refused rather than silently ignored, so an app never
685
+ * believes it published something it didn't. Not `ownerActingAlone`:
686
+ * the record is the subject's own, so an owner principal grants it no
687
+ * reach the subject lacks.
688
+ * See docs/spec/access-control.md § Delegation: principal and subject.
689
+ */
690
+ mayGrantAccess() {
691
+ return !this.delegated || this.principalEntityId === this.stack.ownerEntityId;
692
+ }
693
+ /**
694
+ * Whether one identity, on its own, may decide who else reaches `record`
695
+ * — the owner-or-creator rule a reshare enforces, asked of one
696
+ * side at a time. See
697
+ * docs/spec/access-control.md § Delegation: principal and subject.
698
+ */
699
+ mayReshare(entityId, record) {
700
+ if (!entityId)
701
+ return false;
702
+ return entityId === this.stack.ownerEntityId || entityId === record.entityId;
703
+ }
704
+ /**
705
+ * A record this request may read, or null. Never throws
706
+ * StackPermissionError: an unreadable record answers exactly as a missing
707
+ * one does, so a caller learns only what it may read.
708
+ * See docs/spec/access-control.md § Errors and information exposure.
709
+ */
710
+ async get(id, opts = {}) {
711
+ const record = await this.stack.get(id, opts);
712
+ if (!record)
713
+ return null;
714
+ if (!(await this.canRead(record)))
715
+ return null;
716
+ return presentDeleted(record);
717
+ }
718
+ /**
719
+ * Runs through this.query(), so canRead() still applies — this answers a
720
+ * question the caller asks on its own behalf. includeUnlisted is passed
721
+ * only when the request is the owner acting alone, per
722
+ * docs/spec/identity.md § DID bindings.
723
+ */
724
+ async getEntityByDid(did) {
725
+ return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.stack.features), this.ownerActingAlone);
726
+ }
727
+ async getOwnerEntity() {
728
+ return this.getEntityByDid(this.stack.ownerEntityId);
729
+ }
730
+ /**
731
+ * Query records, filtered to those this request can read. Pages are
732
+ * filtered then refilled, so a page may slightly overshoot `limit` but
733
+ * never skips a record. Grants are prefetched once, cursor-walked to
734
+ * exhaustion.
735
+ */
736
+ async query(query = {}) {
737
+ assertValidSort(query.sort);
738
+ assertSortCapability(query.sort, this.stack.features);
739
+ assertValidRelatedTo(query.filter?.relatedTo);
740
+ if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
741
+ throw new StackPermissionError('includeUnlisted is owner-only');
742
+ }
743
+ const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
744
+ const records = [];
745
+ const maxFetched = limit * 10;
746
+ let totalFetched = 0;
747
+ const prefetchedGrants = this.principalEntityId ? await this.loadGrants() : undefined;
748
+ // Scoped to this query, like prefetchedGrants beside it: every
749
+ // candidate Record shares one roster resolution per group, and nothing
750
+ // is carried into the next operation.
751
+ const groupRoles = new Map();
752
+ let page = { records: [], cursor: query.cursor ?? null };
753
+ do {
754
+ page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
755
+ totalFetched += page.records.length;
756
+ for (const record of page.records) {
757
+ // Projected here as well as in get(), or `includeDeleted` would be
758
+ // a strictly better read channel than the fetch-by-ID it is
759
+ // supposed to match.
760
+ if (await this.canRead(record, prefetchedGrants, groupRoles)) {
761
+ records.push(presentDeleted(record));
762
+ }
763
+ }
764
+ } while (records.length < limit && page.cursor && totalFetched < maxFetched);
765
+ return { records, cursor: page.cursor };
766
+ }
767
+ /**
768
+ * Apply a change set on behalf of the subject. Authority is resolved
769
+ * **per key** and every gate reads the record as it stands, never as the
770
+ * change set would leave it: a widened `permissions` never satisfies the
771
+ * read check on a `parentId` named in the same call, and a `_group`
772
+ * roster never satisfies the admin check that same call has to pass.
773
+ * One refused key refuses the whole call, so nothing is partially
774
+ * applied and no key is silently dropped.
775
+ * See docs/spec/access-control.md § Composing a change set.
776
+ */
777
+ async mutate(id, changes, opts = {}) {
778
+ // Ahead of every gate: a malformed change set is a validation error
779
+ // for every requester, rather than one for the owner and a permission
780
+ // refusal for everyone else.
781
+ assertNonEmptyChangeSet(changes);
782
+ if (changes.contentPatch) {
783
+ const patchErrors = validatePatchValues(changes.contentPatch);
784
+ if (patchErrors.length > 0)
785
+ throw new StackValidationError(patchErrors);
786
+ }
787
+ const reshares = changes.permissions !== undefined || changes.unlisted !== undefined;
788
+ const writes = changes.contentPatch !== undefined ||
789
+ changes.associations !== undefined ||
790
+ changes.parentId !== undefined;
791
+ // requireUpdatable() reads the record and applies the write gate; the
792
+ // reshare keys need the record before their own gate, and a change set
793
+ // carrying only those must not be held to the write gate it doesn't
794
+ // need. One read either way.
795
+ const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
796
+ if (writes && reshares)
797
+ await this.requireReshareOf(record);
798
+ if (changes.contentPatch) {
799
+ const patch = changes.contentPatch;
800
+ // Value-wise, not presence-wise: a client that reads a card, edits
801
+ // its `name` and sends the whole content object back is not setting
802
+ // the binding it round-trips, and `name` is writable by record-level
803
+ // permission. Same predicate restoreVersion() applies to a snapshot.
804
+ this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
805
+ // Likewise value-wise: re-sending the DID a card already holds
806
+ // claims nothing, and immutability refuses changing it regardless.
807
+ if ('did' in patch && patch.did !== record.content.did) {
808
+ this.requireOwnerForOwnerDid(record.typeId, patch.did);
809
+ }
810
+ await this.requireFileRefAccess(record.typeId, patch);
811
+ }
812
+ // Every association the change set would add is a reference this
813
+ // requester has to be allowed to create — asked against the stored
814
+ // set, so an association already on the record is not re-gated.
815
+ for (const assoc of changes.associations ?? []) {
816
+ if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
817
+ continue;
818
+ await this.requireAssociationAccess(record.typeId, assoc);
819
+ }
820
+ if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
821
+ throw new StackPermissionError();
822
+ }
823
+ return this.stack.mutate(id, changes, { ...opts, ...this.actor });
824
+ }
825
+ async patchContent(id, patch, opts = {}) {
826
+ return this.mutate(id, { contentPatch: patch }, opts);
827
+ }
828
+ /**
829
+ * The record, having established that this request may decide who else
830
+ * reaches it — the owner-or-creator rule, asked of both sides of a
831
+ * delegation, or a Group's admin rule for a `_group`. The gate the
832
+ * `permissions` and `unlisted` keys carry, which the write bit
833
+ * deliberately does not confer.
834
+ */
835
+ async requireReshareable(id) {
836
+ const record = await this.stack.get(id);
837
+ if (!record)
838
+ throw new StackNotFoundError(`Record not found: "${id}"`);
839
+ await this.requireOwnerForGrantRecord(record);
840
+ await this.requireReshareOf(record);
841
+ // Reached through its own gate rather than requireUpdatable(), so the
842
+ // soft-delete refusal has to be asked here too — after the authority
843
+ // decision above, for the reason refuseIfDeleted() gives.
844
+ return this.refuseIfDeleted(record, true);
845
+ }
846
+ /** The reshare decision alone, for a record already read and write-gated. */
847
+ async requireReshareOf(record) {
848
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
849
+ // Group management, not authorship: a creator later demoted from the
850
+ // admin roster shouldn't retain a side door to reassign who can read
851
+ // or write the group record.
852
+ if (!this.isGroupManager(record))
853
+ throw await this.denialFor(record);
854
+ return;
855
+ }
856
+ // Intersected like every other authority here: the principal must hold
857
+ // the verb, and the subject must be able to reach this record —
858
+ // without which an owner principal would carry its subject to records
859
+ // the subject cannot touch. create() withholds the same reach via
860
+ // mayGrantAccess().
861
+ if (!this.mayReshare(this.principalEntityId, record))
862
+ throw await this.denialFor(record);
863
+ if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
864
+ throw await this.denialFor(record);
865
+ }
866
+ }
867
+ /**
868
+ * Naming the software behind a key is the trust decision the `_app`
869
+ * registry exists to record, so both halves of that binding — `did` and
870
+ * `appId` — belong to the owner alone. Same reasoning that makes `_app`
871
+ * ungrantable, applied to the fields a lookup reads. Registering a card
872
+ * is already owner-only; without this, record-level `write` shared on a
873
+ * card would be a second way in: a card carrying no DID yet could be
874
+ * pointed at a write-holder's own key while keeping the name the owner
875
+ * gave it, or relabelled to claim another app's `appId`. `name` and
876
+ * `version` stay writable — they are display, not lookup.
877
+ *
878
+ * Owner *acting alone*, in both directions: a delegated app never holds
879
+ * it, and an owner principal doesn't lend it to a subject holding
880
+ * record-level `write` on a card — which would reopen the same route
881
+ * from the other side.
882
+ *
883
+ * `_entity` deliberately does not get this rule: naming people is what a
884
+ * contacts app does, so its cards stay writable by grant. Uniqueness and
885
+ * immutability still bind them. See docs/spec/identity.md § DID bindings.
886
+ */
887
+ requireOwnerForAppIdentity(typeId, touches) {
888
+ if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
889
+ return;
890
+ if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
891
+ return;
892
+ this.requireOwnerActingAlone('Only the stack owner may set an _app record’s did or appId');
893
+ }
894
+ /**
895
+ * A self-reported `appId` must agree with the `_app` card naming the
896
+ * principal's DID, where the owner registered one. `principalId` is
897
+ * verified, so letting the pair disagree would leave a verified principal
898
+ * claiming a name the owner gave different software — the cross-check the
899
+ * registry exists for, refused at the write instead of left to each reader.
900
+ * A principal with no card keeps `appId` as the bare self-report it is for
901
+ * every undelegated writer.
902
+ * See docs/spec/identity.md § Attribution and what can be trusted.
903
+ */
904
+ async requireAppIdMatchesPrincipal(appId) {
905
+ if (appId === undefined || !this.delegated)
906
+ return;
907
+ const card = await findFirstMatch((q) => this.stack.query(q), {
908
+ filter: {
909
+ baseId: SYSTEM_TYPES.APP,
910
+ includeDeleted: true,
911
+ includeUnlisted: true,
912
+ ...(filtersContent(this.stack.features) && {
913
+ content: { did: this.principalEntityId },
914
+ }),
915
+ },
916
+ }, (r) => r.content.did === this.principalEntityId);
917
+ if (card && card.content.appId !== appId) {
918
+ throw new StackPermissionError(`appId "${appId}" is not the appId registered for this principal`);
919
+ }
920
+ }
921
+ /**
922
+ * The owner's own DID is the one `_entity` binding a grantee may not claim.
923
+ * `ownerProfile` adopts whichever card holds it, so a card minted by
924
+ * someone else becomes the stack's own profile, and uniqueness then makes
925
+ * that permanent. Every other DID stays open to a contacts app, which is
926
+ * the reach `_entity` is grantable for.
927
+ * See docs/spec/identity.md § DID bindings.
928
+ */
929
+ requireOwnerForOwnerDid(typeId, did) {
930
+ if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
931
+ return;
932
+ if (did !== this.stack.ownerEntityId)
933
+ return;
934
+ this.requireOwnerActingAlone('Only the stack owner may claim the owner’s own did');
935
+ }
936
+ /**
937
+ * A `_grant` Record *is* authority, so rewriting one is the escalation
938
+ * UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
939
+ * existing grant rather than minting a fresh one. `grant()` and `revoke()`
940
+ * live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
941
+ * only: reading a grant Record and its history stays on the ordinary gate.
942
+ * See docs/spec/access-control.md § Type-level grants.
943
+ */
944
+ async requireOwnerForGrantRecord(record) {
945
+ if (baseIdOf(record.typeId) !== SYSTEM_TYPES.GRANT)
946
+ return;
947
+ if (this.ownerActingAlone)
948
+ return;
949
+ throw await this.denialFor(record, 'Only the stack owner may write a _grant record');
950
+ }
951
+ async associate(id, association, opts = {}) {
952
+ const record = await this.requireUpdatable(id);
953
+ await this.requireAssociationAccess(record.typeId, association);
954
+ return this.stack.associate(id, association, { ...opts, ...this.actor });
955
+ }
956
+ async dissociate(id, association, opts = {}) {
957
+ await this.requireUpdatable(id);
958
+ return this.stack.dissociate(id, association, { ...opts, ...this.actor });
959
+ }
960
+ /**
961
+ * Hard delete is owner-only: it is irreversible and destroys version
962
+ * history, so neither the write bit nor delete-own/delete-any grants
963
+ * reach it, and delegation doesn't carry it either. Everyone else is
964
+ * limited to soft delete.
965
+ */
966
+ async delete(id, opts = {}) {
967
+ await this.requireDeletable(id);
968
+ if (opts.hard && !this.ownerActingAlone) {
969
+ throw new StackPermissionError('Hard delete is owner-only');
970
+ }
971
+ return this.stack.delete(id, { ...opts, ...this.actor });
972
+ }
973
+ /**
974
+ * Reverse a soft delete. Gated the same as delete() — undelete is the
975
+ * inverse of soft delete, so granting one direction without the other
976
+ * would be backwards. Idempotent, per Stack.undelete().
977
+ */
978
+ async undelete(id, opts = {}) {
979
+ await this.requireDeletable(id);
980
+ return this.stack.undelete(id, { ...opts, ...this.actor });
981
+ }
982
+ /**
983
+ * History is the mutation/recovery surface, not a read surface — gated
984
+ * like patchContent(), with snapshot `permissions` stripped for everyone but
985
+ * the owner acting alone — a snapshot's permissions are the stack's
986
+ * sharing graph, which delegation is not a route to. Reading history
987
+ * changes nothing, so it is the one path the `_grant` write fence leaves
988
+ * alone: seeing how a Record you can already read got that way is not
989
+ * the escalation that fence exists to stop, and losing it would leave a
990
+ * write-holder unable to audit the Record they hold.
991
+ * See docs/spec/versioning.md § History access.
992
+ */
993
+ async getVersions(id) {
994
+ await this.requireUpdatable(id, { mutating: false });
995
+ const versions = await this.stack.getVersions(id);
996
+ return this.ownerActingAlone ? versions : versions.map(stripVersionPermissions);
997
+ }
998
+ /** See getVersions() — same mutate-surface gate, same permissions stripping. */
999
+ async getVersion(id, version) {
1000
+ await this.requireUpdatable(id, { mutating: false });
1001
+ const target = await this.stack.getVersion(id, version);
1002
+ if (!target)
1003
+ return null;
1004
+ return this.ownerActingAlone ? target : stripVersionPermissions(target);
1005
+ }
1006
+ /**
1007
+ * Re-runs the reference-creation checks against the snapshot, so a
1008
+ * restore can't re-convey access to a file or record the subject can no
1009
+ * longer reach today — the snapshot's `parentId` among them, gated
1010
+ * exactly as a change set's `parentId` gates a destination named directly. Only the
1011
+ * owner acting alone is exempt: under delegation the checks resolve
1012
+ * against the subject, which is whose reach the restore would widen.
1013
+ * See docs/spec/versioning.md § Restore semantics.
1014
+ */
1015
+ async restoreVersion(id, version, opts = {}) {
1016
+ const record = await this.requireUpdatable(id);
1017
+ if (!this.ownerActingAlone) {
1018
+ const target = await this.stack.getVersion(id, version);
1019
+ if (target) {
1020
+ // A rollback that would move a card's binding is the same trust
1021
+ // decision a content patch reserves to the owner, reached by another route.
1022
+ this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
1023
+ record.content[field]);
1024
+ // Only a restore that moves the record *into* a container creates a
1025
+ // reference. A snapshot at the root names nothing, and a parentId
1026
+ // the restore would not change is not re-gated: the record is
1027
+ // already there, so a content rollback is not refused over a move
1028
+ // it isn't making.
1029
+ if (target.parentId !== undefined &&
1030
+ target.parentId !== record.parentId &&
1031
+ !(await this.canReadReferent(target.parentId))) {
1032
+ throw new StackPermissionError();
1033
+ }
1034
+ await this.requireFileRefAccess(target.typeId, target.content);
1035
+ // Gated against the associations the restore will actually write.
1036
+ // A `_group`'s don't roll back at all, so such a restore introduces
1037
+ // no association and there is nothing here to gate — the same
1038
+ // reason a `parentId` the restore would not change is not re-gated
1039
+ // above.
1040
+ if (!isGroupRecord(record)) {
1041
+ for (const association of target.associations ?? []) {
1042
+ await this.requireAssociationAccess(target.typeId, association);
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
1048
+ }
1049
+ /**
1050
+ * Commit a per-record migration — **the owner acting alone, only**.
1051
+ *
1052
+ * Migration is owner-driven by design: `migrateAll()`, the bulk path,
1053
+ * lives on `Stack` and is deliberately absent from `StackClient`, the
1054
+ * same way `grant()`/`revoke()` are. This is its per-record counterpart
1055
+ * and carries the same restriction, rather than inventing a grant model
1056
+ * that the bulk path deliberately doesn't have.
1057
+ *
1058
+ * The restriction is what makes the verb safe to expose at all. Migrate
1059
+ * replaces `content` and `typeId` wholesale, so a grant-based version
1060
+ * would have to re-derive every gate `create()` applies at the
1061
+ * destination *and* every gate `mutate()` applies over the existing
1062
+ * content, and would reopen each one it missed. The sharpest is the
1063
+ * non-owner `_attachment@1` refusal create() carries: without it, a
1064
+ * requester holding a create grant on `_attachment@1` and write access
1065
+ * to any record they authored could migrate that record into the family
1066
+ * naming any `fileId`, then read the bytes through canAccessFile()'s
1067
+ * uploader clause — the exact escalation that carve-out exists to refuse
1068
+ * (see docs/spec/attachments.md § Creating `_attachment@1` records
1069
+ * directly). Ordinary write access to a record is not consent to move it
1070
+ * between families.
1071
+ *
1072
+ * A server implementing `POST /records/:id/migrate` therefore serves it
1073
+ * to the stack owner and answers 403 otherwise. See
1074
+ * docs/spec/data-model.md § Type migrations.
1075
+ */
1076
+ async commitMigration(id, toTypeId, content, opts = {}) {
1077
+ this.requireOwnerActingAlone('Only the stack owner may commit a migration');
1078
+ return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
1079
+ }
1080
+ /**
1081
+ * Store bytes and create an _attachment@1 metadata record (create grant
1082
+ * on `_attachment@1` required; anonymous denied), returning that record.
1083
+ * Authorship and principal are stamped exactly as create() does.
1084
+ */
1085
+ async putAttachment(data, mimeType, filename, appId) {
1086
+ // The one ScopedStack path that reaches the adapter without going
1087
+ // through Stack first — without this, a closed stack would still write
1088
+ // bytes before the delegated create() refused.
1089
+ this.stack.assertOpen();
1090
+ const principal = this.principalEntityId;
1091
+ if (!principal) {
1092
+ throw new StackPermissionError('Anonymous requesters cannot upload attachments');
1093
+ }
1094
+ if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) {
1095
+ throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`);
1096
+ }
1097
+ await this.requireAppIdMatchesPrincipal(appId);
1098
+ assertAttachmentSize(data.byteLength, this.features.limits.attachmentBytes);
1099
+ const fileId = await this.adapter.putAttachment(data);
1100
+ return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
1101
+ fileId,
1102
+ mimeType,
1103
+ size: data.byteLength,
1104
+ ...(filename && { filename }),
1105
+ }, {
1106
+ entityId: this.subjectEntityId ?? undefined,
1107
+ principalId: this.delegated ? principal : undefined,
1108
+ appId,
1109
+ });
1110
+ }
1111
+ /**
1112
+ * Download attachment bytes. Accessible to the owner, a reader of any
1113
+ * referencing record, or the uploader pre-association — the same
1114
+ * predicate as the reference-creation gate (canAccessFile).
1115
+ */
1116
+ async getAttachment(fileId) {
1117
+ if (!(await this.canAccessFile(fileId)))
1118
+ throw new StackPermissionError();
1119
+ return this.stack.getAttachment(fileId);
1120
+ }
1121
+ /**
1122
+ * Delete an attachment. Only the stack owner may delete attachments.
1123
+ * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
1124
+ */
1125
+ async deleteAttachment(fileId) {
1126
+ this.requireOwnerActingAlone('Only the stack owner can delete attachments');
1127
+ return this.stack.deleteAttachment(fileId, this.actor);
1128
+ }
1129
+ /**
1130
+ * Sweep for unreferenced attachment bytes and delete them. Only the stack
1131
+ * owner may run this. Delegates to Stack.collectAttachmentGarbage().
1132
+ */
1133
+ async collectAttachmentGarbage(opts) {
1134
+ this.requireOwnerActingAlone('Only the stack owner can collect attachment garbage');
1135
+ return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
1136
+ }
1137
+ /**
1138
+ * Observe the changes this request may read. The predicate is canRead
1139
+ * applied per event — the same one get() and query() answer with, so a
1140
+ * feed can't disagree with them about what this session sees.
1141
+ *
1142
+ * A record the subscriber cannot read produces no event at all, rather
1143
+ * than an empty or redacted one: the existence of a change is itself a
1144
+ * disclosure, the same reasoning that keeps a count of the whole match
1145
+ * off a query result. See docs/spec/events.md § Permission scoping.
1146
+ */
1147
+ async subscribe(handler, opts = {}) {
1148
+ this.stack.assertOpen();
1149
+ if (this.stack.relaysChanges) {
1150
+ throw new StackRelayScopeError('This stack relays changes from elsewhere, and a scoped view cannot narrow that feed: ' +
1151
+ 'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
1152
+ 'no record to re-check. Subscribe with a session-scoped stack instead.');
1153
+ }
1154
+ // A relaying stack was already refused above, so relaysChanges is
1155
+ // always false here — `since` never has a cursor to mean anything by.
1156
+ assertSinceUsable(opts.since, false);
1157
+ if (opts.includeUnlisted && !this.ownerActingAlone) {
1158
+ throw new StackPermissionError('includeUnlisted is owner-only');
1159
+ }
1160
+ return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
1161
+ }
1162
+ /**
1163
+ * canRead for one event, through the subscription's own cache. Grants
1164
+ * are prefetched once and refilled after an invalidation rather than
1165
+ * re-queried per event, which would be a `_grant` scan for every change
1166
+ * the stack makes.
1167
+ */
1168
+ async canReadCached(record, cache) {
1169
+ const grants = this.principalEntityId ? await cache.grants(() => this.loadGrants()) : undefined;
1170
+ return this.canRead(record, grants, cache.roles);
1171
+ }
1172
+ }
1173
+ //# sourceMappingURL=scoped-stack.js.map