@haverstack/core 0.28.0 → 0.30.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 (94) hide show
  1. package/README.md +9 -2
  2. package/dist/access.d.ts +20 -0
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +26 -0
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter-entry.d.ts +1 -1
  7. package/dist/adapter-entry.d.ts.map +1 -1
  8. package/dist/adapter-entry.js +1 -1
  9. package/dist/adapter-entry.js.map +1 -1
  10. package/dist/changes.d.ts +15 -2
  11. package/dist/changes.d.ts.map +1 -1
  12. package/dist/changes.js +62 -6
  13. package/dist/changes.js.map +1 -1
  14. package/dist/combine.d.ts.map +1 -1
  15. package/dist/combine.js +1 -4
  16. package/dist/combine.js.map +1 -1
  17. package/dist/errors.d.ts +173 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +208 -0
  20. package/dist/errors.js.map +1 -0
  21. package/dist/grants.d.ts +84 -0
  22. package/dist/grants.d.ts.map +1 -0
  23. package/dist/grants.js +157 -0
  24. package/dist/grants.js.map +1 -0
  25. package/dist/id.d.ts +7 -5
  26. package/dist/id.d.ts.map +1 -1
  27. package/dist/id.js +17 -15
  28. package/dist/id.js.map +1 -1
  29. package/dist/identity-bindings.d.ts +13 -0
  30. package/dist/identity-bindings.d.ts.map +1 -0
  31. package/dist/identity-bindings.js +42 -0
  32. package/dist/identity-bindings.js.map +1 -0
  33. package/dist/index.d.ts +7 -4
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/limits.d.ts +23 -0
  38. package/dist/limits.d.ts.map +1 -0
  39. package/dist/limits.js +35 -0
  40. package/dist/limits.js.map +1 -0
  41. package/dist/merge.d.ts +1 -1
  42. package/dist/merge.js +2 -2
  43. package/dist/merge.js.map +1 -1
  44. package/dist/query-validation.d.ts +80 -0
  45. package/dist/query-validation.d.ts.map +1 -0
  46. package/dist/query-validation.js +223 -0
  47. package/dist/query-validation.js.map +1 -0
  48. package/dist/record-changes.d.ts +89 -0
  49. package/dist/record-changes.d.ts.map +1 -0
  50. package/dist/record-changes.js +201 -0
  51. package/dist/record-changes.js.map +1 -0
  52. package/dist/record-id.d.ts +70 -0
  53. package/dist/record-id.d.ts.map +1 -0
  54. package/dist/record-id.js +127 -0
  55. package/dist/record-id.js.map +1 -0
  56. package/dist/schema.d.ts.map +1 -1
  57. package/dist/schema.js +56 -5
  58. package/dist/schema.js.map +1 -1
  59. package/dist/scoped-stack.d.ts +460 -0
  60. package/dist/scoped-stack.d.ts.map +1 -0
  61. package/dist/scoped-stack.js +1163 -0
  62. package/dist/scoped-stack.js.map +1 -0
  63. package/dist/stack-reads.d.ts +38 -0
  64. package/dist/stack-reads.d.ts.map +1 -0
  65. package/dist/stack-reads.js +79 -0
  66. package/dist/stack-reads.js.map +1 -0
  67. package/dist/stack.d.ts +108 -700
  68. package/dist/stack.d.ts.map +1 -1
  69. package/dist/stack.js +235 -2286
  70. package/dist/stack.js.map +1 -1
  71. package/dist/testing.d.ts +3 -14
  72. package/dist/testing.d.ts.map +1 -1
  73. package/dist/testing.js +30 -42
  74. package/dist/testing.js.map +1 -1
  75. package/dist/types.d.ts +107 -30
  76. package/dist/types.d.ts.map +1 -1
  77. package/dist/types.js +16 -0
  78. package/dist/types.js.map +1 -1
  79. package/dist/validate.d.ts +31 -0
  80. package/dist/validate.d.ts.map +1 -1
  81. package/dist/validate.js +150 -3
  82. package/dist/validate.js.map +1 -1
  83. package/dist/wire-entry.d.ts +3 -3
  84. package/dist/wire-entry.d.ts.map +1 -1
  85. package/dist/wire-entry.js +3 -3
  86. package/dist/wire-entry.js.map +1 -1
  87. package/dist/wire-record.d.ts +16 -1
  88. package/dist/wire-record.d.ts.map +1 -1
  89. package/dist/wire-record.js +57 -1
  90. package/dist/wire-record.js.map +1 -1
  91. package/dist/wire-request.d.ts.map +1 -1
  92. package/dist/wire-request.js +3 -2
  93. package/dist/wire-request.js.map +1 -1
  94. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -13,632 +13,26 @@
13
13
  *
14
14
  * Apps should never talk to a StackAdapter directly.
15
15
  */
16
- import { generateId, generateIdForTimestamp, isValidIdFormat, idTimestamp, MAX_ID_TIMESTAMP, } from './id.js';
16
+ import { generateId, generateIdForTimestamp } from './id.js';
17
17
  import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
18
- import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, } from './validate.js';
18
+ import { validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
19
19
  import { applyMergePatch } from './merge.js';
20
- import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone, validatePermissions, } from './access.js';
20
+ import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './access.js';
21
21
  import { compareRecordedAttachments } from './attachment-download.js';
22
- import { ChangeEmitter, RelayDelivery, Subscription, buildEmission, matchesFilter, passesUnlistedBoundary, } from './changes.js';
23
- import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
22
+ import { ChangeEmitter, RelayDelivery, buildEmission, assertSinceUsable } from './changes.js';
23
+ import { SYSTEM_TYPES } from './types.js';
24
+ import { StackClosedError, StackConflictError, StackMigrationError, StackNotFoundError, StackPermissionError, StackQueryError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
+ import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
26
+ import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, matchesGrantTarget, validateGrantTarget, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
27
+ import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
28
+ import { assertAttachmentSize, assertContentSize } from './limits.js';
29
+ import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
30
+ import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
31
+ import { associationEqual, assertNonEmptyChangeSet, changeSetOps, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
32
+ import { ScopedStack } from './scoped-stack.js';
24
33
  // -------------------------------------------------------
25
34
  // Supporting types
26
35
  // -------------------------------------------------------
27
- /** Sentinel: filter.baseId resolved to zero matching types. */
28
- const EMPTY_FAMILY = Symbol('empty-family');
29
- /**
30
- * Valid GrantAction values, for runtime validation in Stack.grant().
31
- * Built from GRANT_ACTIONS (types.ts), the source of truth GrantAction is
32
- * itself derived from — so this can't drift from the type.
33
- */
34
- const GRANT_ACTION_SET = new Set(GRANT_ACTIONS);
35
- /**
36
- * The read actions that make a mutate action coherent, scope for scope: a
37
- * `-any` verb needs read over the same reach it can mutate, a `-own` verb
38
- * needs read over its author's own. A grant carrying neither conveys the
39
- * verb to nobody. `create` has no companion — writing a Record you then
40
- * cannot read is the drop-box, and it discloses nothing.
41
- * See docs/spec/access-control.md § Write implies read.
42
- */
43
- const READ_COMPANIONS = new Map([
44
- ['update-own', ['read-own', 'read-any']],
45
- ['delete-own', ['read-own', 'read-any']],
46
- ['update-any', ['read-any']],
47
- ['delete-any', ['read-any']],
48
- ]);
49
- /**
50
- * Whether one grant's action list conveys `action`. The companion has to
51
- * sit in the same `_grant` Record, not merely somewhere in the grantee's
52
- * set: a grant is revoked whole, so a rule satisfied across two records
53
- * would let revoking the read one leave a mutate-without-read grant
54
- * standing — the configuration this rule exists to refuse, arrived at
55
- * without anyone writing it.
56
- */
57
- function grantConveys(actions, action) {
58
- if (!actions.includes(action))
59
- return false;
60
- const companions = READ_COMPANIONS.get(action);
61
- return !companions || companions.some((c) => actions.includes(c));
62
- }
63
- /** Direct (non-roster) match between a stored _grant's content and a GrantTarget. */
64
- function matchesGrantTarget(content, target) {
65
- if (target !== null && typeof target === 'object') {
66
- // Guarded so an absent groupId can't match the absent granteeGroupId on
67
- // every entity-targeted and default grant — `undefined === undefined`
68
- // would otherwise sweep them all into a revoke aimed at one group.
69
- if (!target.groupId)
70
- return false;
71
- return content.granteeGroupId === target.groupId;
72
- }
73
- if (target === null) {
74
- return !content.granteeEntityId && !content.granteeGroupId;
75
- }
76
- return content.granteeEntityId === target;
77
- }
78
- /**
79
- * Reject a grant target that names nobody. An empty groupId or entityId is
80
- * falsy, so a stored record carrying one reads as a *default* grant — every
81
- * authenticated entity — instead of the target the caller meant. `null` is
82
- * the only way to say "default".
83
- */
84
- function validateGrantTarget(target) {
85
- if (target === null)
86
- return;
87
- if (typeof target === 'string') {
88
- if (target.length === 0) {
89
- throw new StackQueryError('A grant target entityId cannot be empty. Pass null for a default grant.');
90
- }
91
- return;
92
- }
93
- if (typeof target.groupId !== 'string' || target.groupId.length === 0) {
94
- throw new StackQueryError('A group grant target requires a non-empty groupId.');
95
- }
96
- // Not a format check: granteeGroupId is a reference to an existing
97
- // Record, like parentId or an association's recordId, and none of those
98
- // are parsed either. One that resolves to nothing simply denies.
99
- }
100
- /**
101
- * Whether a stored _grant covers `grantee`: a direct DID match, roster
102
- * membership when `allowGroup`, or a default when `allowDefault`. Presence
103
- * decides the tier, never truthiness — an empty grantee field names nobody.
104
- * Module-level so the access checks and listGrants() cannot drift apart.
105
- * See docs/spec/access-control.md § Type-level grants.
106
- */
107
- async function grantCoversGrantee(c, grantee, opts) {
108
- const namesEntity = c.granteeEntityId !== undefined;
109
- const namesGroup = c.granteeGroupId !== undefined;
110
- if (!namesEntity && !namesGroup)
111
- return opts.allowDefault;
112
- if (namesEntity && c.granteeEntityId !== grantee)
113
- return false;
114
- if (namesGroup) {
115
- if (!opts.allowGroup)
116
- return false;
117
- if (!c.granteeGroupId)
118
- return false;
119
- const role = await resolveGroupRoleMemoized(c.granteeGroupId, grantee, opts.groupRoles, opts.resolveRecord);
120
- if (role === null)
121
- return false;
122
- }
123
- return true;
124
- }
125
- /**
126
- * An entity's role on a `_group` roster, memoized in the caller's
127
- * `groupRoles` map. That map is built per operation and threaded alongside
128
- * `prefetchedGrants`, so no resolved role outlives the operation that
129
- * resolved it — removal from a group must never go stale.
130
- * See docs/spec/access-control.md § Type-level grants.
131
- */
132
- async function resolveGroupRoleMemoized(groupId, entityId, groupRoles, resolveRecord) {
133
- const key = `${groupId}:${entityId}`;
134
- const cached = groupRoles.get(key);
135
- if (cached !== undefined)
136
- return cached;
137
- const group = await resolveRecord(groupId);
138
- // Only a real `_group` Record carries a roster. Without the family check
139
- // any Record's relationship associations would serve as one, and a group
140
- // migrated out of the family would keep resolving after it had stopped
141
- // being a group.
142
- const role = group && baseIdOf(group.typeId) === SYSTEM_TYPES.GROUP
143
- ? groupRoleFromAssociations(group.associations, entityId)
144
- : null;
145
- groupRoles.set(key, role);
146
- return role;
147
- }
148
- /**
149
- * System type families grant() refuses to target: a grant on any of them
150
- * would let the grantee mint their own grants, touch stack config, or
151
- * register an app card claiming a DID that isn't theirs — the last of
152
- * which is what verified app attribution rests on. See
153
- * docs/spec/access-control.md § Type-level grants.
154
- */
155
- const UNGRANTABLE_SYSTEM_TYPES = new Set([
156
- SYSTEM_TYPES.GRANT,
157
- SYSTEM_TYPES.CONFIG,
158
- SYSTEM_TYPES.APP,
159
- ]);
160
- /**
161
- * Content fields that are lookup keys rather than display values: a card
162
- * claims one, and something later resolves through it. Every one of them is
163
- * immutable once set. See docs/spec/identity.md § DID bindings.
164
- */
165
- const BINDING_FIELDS = new Map([
166
- [SYSTEM_TYPES.APP, ['did', 'appId']],
167
- [SYSTEM_TYPES.ENTITY, ['did']],
168
- ]);
169
- /**
170
- * The subset that is additionally unique per stack: the fields something
171
- * resolves *by*. A Record's `principalId` finds its card by `_app.did` and
172
- * its `entityId` by `_entity.did`, so a second card claiming either leaves
173
- * that lookup without a single answer — and ambiguity is all an
174
- * impersonating card needs.
175
- *
176
- * `_app.appId` is deliberately absent. Nothing resolves a card by it — the
177
- * cross-check reaches the card by `did` and only compares `appId` — so
178
- * uniqueness would buy no disambiguation, while forbidding the second card
179
- * key rotation is supposed to produce: `appId` is required, so a
180
- * replacement card for the same software necessarily repeats it. Moving one
181
- * card onto another's `appId` is what immutability already refuses.
182
- * See docs/spec/identity.md § DID bindings.
183
- */
184
- const UNIQUE_BINDING_FIELDS = new Map([
185
- [SYSTEM_TYPES.APP, ['did']],
186
- [SYSTEM_TYPES.ENTITY, ['did']],
187
- ]);
188
- const bindingFieldsOf = (family) => BINDING_FIELDS.get(family) ?? [];
189
- const uniqueBindingFieldsOf = (family) => UNIQUE_BINDING_FIELDS.get(family) ?? [];
190
- /**
191
- * Root of the Stack error taxonomy. A single `instanceof StackError` answers
192
- * "is this a Stack-domain error or a bug?" — the question a server's error
193
- * middleware asks before serializing a wire body, and one a nine-arm
194
- * instanceof ladder answers only by exhaustion. Every subclass carries its
195
- * discriminator as an instance `code`, so serialization is a lookup rather
196
- * than a chain of class tests.
197
- *
198
- * Membership implies a wire mapping: every code has an entry in
199
- * WIRE_ERROR_STATUS. Errors with no wire representation (IdGenerationError,
200
- * InvalidDidError) deliberately stay outside this hierarchy.
201
- *
202
- * Subclassing adds no hierarchy beyond this root — notably
203
- * StackVersionConflictError is a sibling of StackConflictError, not a
204
- * subtype. See docs/spec/wire-format.md § Error responses.
205
- */
206
- export class StackError extends Error {
207
- }
208
- export class StackValidationError extends StackError {
209
- errors;
210
- static code = 'validation';
211
- code = StackValidationError.code;
212
- constructor(errors) {
213
- super(`Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'));
214
- this.errors = errors;
215
- this.name = 'StackValidationError';
216
- }
217
- }
218
- export class StackMigrationError extends StackError {
219
- static code = 'migration';
220
- code = StackMigrationError.code;
221
- constructor(message) {
222
- super(message);
223
- this.name = 'StackMigrationError';
224
- }
225
- }
226
- /** Thrown by ScopedStack when a requester lacks permission for the operation. */
227
- export class StackPermissionError extends StackError {
228
- static code = 'permission';
229
- code = StackPermissionError.code;
230
- constructor(message = 'Permission denied') {
231
- super(message);
232
- this.name = 'StackPermissionError';
233
- }
234
- }
235
- /** Thrown when a record (or specific version) does not exist. */
236
- export class StackNotFoundError extends StackError {
237
- static code = 'not_found';
238
- code = StackNotFoundError.code;
239
- constructor(message) {
240
- super(message);
241
- this.name = 'StackNotFoundError';
242
- }
243
- }
244
- /** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */
245
- export class StackConflictError extends StackError {
246
- static code = 'conflict';
247
- code = StackConflictError.code;
248
- constructor(message) {
249
- super(message);
250
- this.name = 'StackConflictError';
251
- }
252
- }
253
- /**
254
- * Thrown when an `ifVersion` precondition doesn't match a record's current
255
- * version. Deliberately not a StackConflictError subtype — the two have
256
- * different recovery stories and HTTP statuses (409 vs. 412). See
257
- * docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
258
- */
259
- export class StackVersionConflictError extends StackError {
260
- recordId;
261
- expectedVersion;
262
- actualVersion;
263
- static code = 'version_conflict';
264
- code = StackVersionConflictError.code;
265
- constructor(message, recordId, expectedVersion, actualVersion) {
266
- super(message);
267
- this.recordId = recordId;
268
- this.expectedVersion = expectedVersion;
269
- this.actualVersion = actualVersion;
270
- this.name = 'StackVersionConflictError';
271
- }
272
- }
273
- /**
274
- * Thrown when a request is structurally malformed — not a content-validation
275
- * failure, but input the adapter/server can't even interpret (e.g. an
276
- * undecodable pagination cursor, a malformed TypeId, search text the engine
277
- * cannot parse, or a typeId no definition exists for). Distinct from
278
- * StackValidationError, which means the request was well-formed but content
279
- * failed schema validation.
280
- *
281
- * Naming something absent belongs here rather than under `not_found`: a
282
- * request whose *type* is undefined never addressed a record, so answering
283
- * 404 would say a record was missing when none was asked for.
284
- */
285
- export class StackQueryError extends StackError {
286
- static code = 'bad_request';
287
- code = StackQueryError.code;
288
- constructor(message) {
289
- super(message);
290
- this.name = 'StackQueryError';
291
- }
292
- }
293
- /**
294
- * Fail loud rather than silently widen: a filter the adapter can't honor
295
- * would otherwise be dropped, returning an unfiltered superset presented
296
- * as the filtered result. Shared by Stack.query() and
297
- * APIAdapter.queryRecords(). See docs/spec/data-model.md
298
- * § Capability-gated filters.
299
- */
300
- export function assertQueryCapabilities(filter, capabilities) {
301
- const { content: reach, contentPresent, search } = capabilities.filter;
302
- if (filter?.search && !search) {
303
- throw new StackQueryError('Query uses filter.search, but this adapter does not declare the filter.search capability.');
304
- }
305
- const present = filter?.contentPresent?.length ? filter.contentPresent : undefined;
306
- if (!filter?.content && !present)
307
- return;
308
- if (reach === 'none') {
309
- throw new StackQueryError(`Query uses ${present && !filter?.content ? 'filter.contentPresent' : 'filter.content'}, ` +
310
- 'but this adapter declares filter.content: "none".');
311
- }
312
- if (present && !contentPresent) {
313
- throw new StackQueryError('Query uses filter.contentPresent, but this adapter does not declare the ' +
314
- 'filter.contentPresent capability.');
315
- }
316
- for (const key of [...Object.keys(filter?.content ?? {}), ...(present ?? [])]) {
317
- if (parseContentFilterKey(key).length > 1 && reach !== 'path') {
318
- throw new StackQueryError(`Query uses the nested content path "${key}", but this adapter declares ` +
319
- `filter.content: "${reach}".`);
320
- }
321
- }
322
- }
323
- /**
324
- * Whether a single-segment content filter can be pushed down to the
325
- * adapter. Callers that read a content field pair this with an in-memory
326
- * predicate over the wider result, so the query stays correct against an
327
- * adapter that reaches no content at all — the rung comparison lives here
328
- * rather than at each of them.
329
- */
330
- function filtersContent(features) {
331
- return features.filter.content !== 'none';
332
- }
333
- /** The longest path both SQLite engines can execute — see the spec link below. */
334
- const MAX_CONTENT_PATH_SEGMENTS = 32;
335
- /**
336
- * Split a content filter key into path segments. Write-time validation
337
- * keeps stored field names free of the path metacharacters, but a filter
338
- * arrives from a request body and has made no such promise, so the same
339
- * rule is enforced here. See docs/spec/data-model.md § Content field names.
340
- */
341
- export function parseContentFilterKey(key) {
342
- const segments = key.split('.');
343
- // A SQLite adapter spends two json_each joins per segment against a
344
- // 64-table join limit, so a path past the cap is one the engine could
345
- // not execute. See docs/spec/data-model.md § Nested content paths.
346
- if (segments.length > MAX_CONTENT_PATH_SEGMENTS) {
347
- throw new StackQueryError(`Invalid content filter path "${key}": at most ${MAX_CONTENT_PATH_SEGMENTS} segments.`);
348
- }
349
- for (const segment of segments) {
350
- if (segment === '') {
351
- throw new StackQueryError(`Invalid content filter path "${key}": a path segment cannot be empty.`);
352
- }
353
- if (SEGMENT_METACHARACTER_RE.test(segment)) {
354
- throw new StackQueryError(`Invalid content filter path "${key}": a segment cannot contain any of ` +
355
- `${CONTENT_SEGMENT_METACHARACTERS.join(' ')}.`);
356
- }
357
- }
358
- return segments;
359
- }
360
- /** The only sort fields any adapter maps; anything else is a caller error. */
361
- const VALID_SORT_FIELDS = new Set(['createdAt', 'updatedAt', 'version']);
362
- /** The only two sort directions; see assertValidSort. */
363
- const VALID_SORT_DIRECTIONS = new Set(['asc', 'desc']);
364
- /**
365
- * Reject a sort whose field or direction is outside the closed set the
366
- * types promise. `QuerySort` is typed `'asc' | 'desc'`, but a type is not a
367
- * runtime guard: a server mapping `?direction=` onto a query, or a
368
- * delegated app calling query(), supplies a raw string. A SQLite record
369
- * adapter interpolates the direction straight into `ORDER BY`, so an
370
- * unvalidated value there is a SQL-injection sink reachable from every
371
- * untrusted caller. Validating in the invariant layer — the same reason
372
- * emission and _config protection live here — means no adapter can forget
373
- * it. See docs/spec/data-model.md § Sorting and pagination.
374
- */
375
- export function assertValidSort(sort) {
376
- if (!sort)
377
- return;
378
- if (sort.field !== undefined && sort.contentField !== undefined) {
379
- throw new StackQueryError('A sort names either a native field or a content field, never both.');
380
- }
381
- if (sort.field !== undefined && !VALID_SORT_FIELDS.has(sort.field)) {
382
- throw new StackQueryError(`Invalid sort field "${sort.field}": expected one of createdAt, updatedAt, version.`);
383
- }
384
- if (sort.contentField !== undefined) {
385
- // Parsed by the same rule a filter key is, so a name a filter could
386
- // never address is not one a sort can either — then held to one
387
- // segment, because a value inside an array or object has no single
388
- // position to order its record by (docs/spec/data-model.md
389
- // § Sorting by a content field).
390
- if (parseContentFilterKey(sort.contentField).length > 1) {
391
- throw new StackQueryError(`Invalid sort content field "${sort.contentField}": sorting reaches top-level fields only.`);
392
- }
393
- }
394
- if (sort.direction !== undefined && !VALID_SORT_DIRECTIONS.has(sort.direction)) {
395
- throw new StackQueryError(`Invalid sort direction "${sort.direction}": expected "asc" or "desc".`);
396
- }
397
- }
398
- /**
399
- * Fail loud rather than silently reorder: an adapter that can't honor the
400
- * requested sort would otherwise answer in some other order, which a
401
- * caller paging a bounded window has no way to notice. The companion to
402
- * assertQueryCapabilities(), split from it because a sort is not a filter
403
- * — see docs/spec/data-model.md § Capability-gated filters.
404
- */
405
- export function assertSortCapability(sort, capabilities) {
406
- if (!sort)
407
- return;
408
- if (sort.contentField !== undefined) {
409
- if (!capabilities.sort.contentField) {
410
- throw new StackQueryError('Query uses sort.contentField, but this adapter does not declare the sort.contentField ' +
411
- 'capability.');
412
- }
413
- return;
414
- }
415
- const field = sort.field ?? 'createdAt';
416
- if (!capabilities.sort.fields.includes(field)) {
417
- throw new StackQueryError(`Query sorts by "${field}", which this adapter does not declare in sort.fields.`);
418
- }
419
- }
420
- /** The identifier spaces a relationship target may name. */
421
- const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
422
- /**
423
- * Collect what makes a relationship target malformed. Absence is
424
- * meaningful on `stackUrl` and an external `id` — this stack, and the
425
- * whole namespace — so every part that names something must be non-empty:
426
- * an empty string stores and matches as though it were absent.
427
- * See docs/spec/data-model.md § Relationship targets.
428
- */
429
- function targetErrors(target, path, opts = {}) {
430
- const fail = (message) => [{ path, message }];
431
- if (!target || typeof target !== 'object')
432
- return fail('A relationship target must be an object.');
433
- if (!TARGET_SCOPES.has(target.scope)) {
434
- return fail(`Unknown relationship target scope "${target.scope}": expected "record", "entity" or "external".`);
435
- }
436
- if (target.scope === 'record') {
437
- if (!target.recordId)
438
- return fail('A record target requires a non-empty recordId.');
439
- if (target.stackUrl !== undefined && !target.stackUrl) {
440
- return fail("A record target's stackUrl must be non-empty; omit it to name this stack.");
441
- }
442
- return [];
443
- }
444
- if (target.scope === 'entity') {
445
- return target.entityId ? [] : fail('An entity target requires a non-empty entityId.');
446
- }
447
- if (!target.ns)
448
- return fail('An external target requires a non-empty ns.');
449
- if (target.id === undefined) {
450
- return opts.externalIdOptional ? [] : fail('An external target requires an id.');
451
- }
452
- return target.id ? [] : fail("An external target's id must be non-empty when present.");
453
- }
454
- /**
455
- * Reject a relationship target outside the closed set the types promise.
456
- * A discriminated union is not a runtime guard — a server mapping a
457
- * request body onto an association supplies raw JSON — and an
458
- * unrecognized scope would otherwise be stored under the one arm that
459
- * names a Record in this stack. See docs/spec/data-model.md
460
- * § Relationship targets.
461
- */
462
- function validateAssociation(association, path = 'association') {
463
- if (association?.kind !== 'relationship')
464
- return [];
465
- return targetErrors(association.target, `${path}.target`);
466
- }
467
- /** validateAssociation() over a create's `associations` array. */
468
- function validateAssociations(associations, path = 'associations') {
469
- return (associations ?? []).flatMap((a, i) => validateAssociation(a, `${path}[${i}]`));
470
- }
471
- /**
472
- * Reject a relationship filter that names neither a label nor a target,
473
- * or whose target is malformed. `RelatedToFilter` promises one half is
474
- * always present; without the runtime check a filter decoded from a
475
- * request could arrive empty and match every record carrying any
476
- * relationship. See docs/spec/data-model.md § Filter.
477
- */
478
- export function assertValidRelatedTo(relatedTo) {
479
- if (!relatedTo)
480
- return;
481
- if (relatedTo.label === undefined && relatedTo.target === undefined) {
482
- throw new StackQueryError('filter.relatedTo must name a label, a target, or both — "any relationship at all" is not a filter.');
483
- }
484
- if (relatedTo.target === undefined)
485
- return;
486
- const errors = targetErrors(relatedTo.target, 'filter.relatedTo.target', {
487
- externalIdOptional: true,
488
- });
489
- if (errors.length > 0)
490
- throw new StackQueryError(errors[0].message);
491
- }
492
- /**
493
- * Thrown when an attachment upload exceeds the adapter's declared
494
- * `limits.attachmentBytes` ceiling — checked client-side before any bytes
495
- * are sent; a server still enforces 413 authoritatively regardless. See
496
- * docs/spec/wire-format.md § Attachments.
497
- */
498
- export class StackPayloadTooLargeError extends StackError {
499
- static code = 'payload_too_large';
500
- code = StackPayloadTooLargeError.code;
501
- constructor(message) {
502
- super(message);
503
- this.name = 'StackPayloadTooLargeError';
504
- }
505
- }
506
- /**
507
- * Thrown when a server abandons an operation for taking too long — in
508
- * practice a full-text search, the one query whose cost the sanitizers
509
- * bound the *grammar* of but not the execution of (see
510
- * docs/spec/data-model.md § Capability-gated filters).
511
- *
512
- * Never produced in-process: both SQLite engines run synchronously, so
513
- * there is nothing to interrupt from inside the call. It exists so a
514
- * server that bounds query time has a class to serialize, and so the app
515
- * catching it can tell "too expensive, narrow it and retry" from
516
- * StackQueryError's "malformed, don't bother retrying" — the distinction
517
- * that would be lost if a timeout reused `bad_request`.
518
- */
519
- export class StackTimeoutError extends StackError {
520
- static code = 'timeout';
521
- code = StackTimeoutError.code;
522
- constructor(message) {
523
- super(message);
524
- this.name = 'StackTimeoutError';
525
- }
526
- }
527
- /** Shared by Stack.putAttachment() and ScopedStack.putAttachment(). */
528
- function assertAttachmentSize(byteLength, attachmentBytes) {
529
- if (attachmentBytes !== null && byteLength > attachmentBytes) {
530
- throw new StackPayloadTooLargeError(`Attachment (${byteLength} bytes) exceeds the ${attachmentBytes}-byte limit.`);
531
- }
532
- }
533
- /**
534
- * The content half of the same pre-check, on Stack.create() and
535
- * Stack.update(). Local adapters declare `limits.contentBytes: null` and skip
536
- * the serialization entirely; only a server declares a ceiling, and its
537
- * own request-size limit stays authoritative — this just spares an app the
538
- * round trip and gives it a typed failure instead of a 413 it has to
539
- * interpret. See docs/spec/wire-format.md § Request size limits.
540
- */
541
- function assertContentSize(content, contentBytes, what) {
542
- if (contentBytes === null)
543
- return;
544
- const byteLength = new TextEncoder().encode(JSON.stringify(content)).length;
545
- if (byteLength > contentBytes) {
546
- throw new StackPayloadTooLargeError(`${what} (${byteLength} bytes) exceeds the ${contentBytes}-byte limit.`);
547
- }
548
- }
549
- /**
550
- * Thrown by defineType() when redefining an existing typeId with a schema
551
- * change beyond additive evolution. The remedy is a new version, never an
552
- * in-place redefinition. See docs/spec/data-model.md § Schema drift
553
- * detection.
554
- */
555
- export class StackSchemaDriftError extends StackError {
556
- typeId;
557
- violations;
558
- static code = 'schema_drift';
559
- code = StackSchemaDriftError.code;
560
- constructor(typeId, violations) {
561
- super(`Schema drift detected for type "${typeId}": the stored schema and the new definition ` +
562
- `differ beyond additive evolution (new optional fields only). Bump the version instead ` +
563
- `of redefining "${typeId}" in place — e.g. defineType(\`${baseIdOf(typeId)}@${(parseTypeId(typeId)?.version ?? 0) + 1}\`, ...) plus registerMigration().\n` +
564
- violations.map((v) => ` ${v.path || '(root)'}: ${v.message}`).join('\n'));
565
- this.typeId = typeId;
566
- this.violations = violations;
567
- this.name = 'StackSchemaDriftError';
568
- }
569
- }
570
- /**
571
- * Thrown when a Stack or ScopedStack is used after close(). Deliberately
572
- * outside the StackError taxonomy, alongside IdGenerationError and
573
- * InvalidDidError: a caller holding a closed client is a local programming
574
- * error with no wire representation — no server ever responds with it.
575
- * See docs/spec/adapters.md § Lifecycle.
576
- */
577
- export class StackClosedError extends Error {
578
- constructor(message = 'This Stack has been closed.') {
579
- super(message);
580
- this.name = 'StackClosedError';
581
- }
582
- }
583
- /**
584
- * Thrown when a scoped view is asked to observe a stack whose adapter
585
- * relays a remote feed. Outside the StackError taxonomy for the same
586
- * reason StackClosedError is: it reports a topology the caller assembled,
587
- * not a state a request can be in.
588
- *
589
- * A relayed frame is scoped by the authority that opened the feed, and a
590
- * narrower scope cannot re-derive that decision — a purge leaves no record
591
- * to check `canRead` against. Delivering anyway would break the promise
592
- * that a subscriber never sees what it may not read; delivering only local
593
- * writes would silently drop every change made elsewhere, which is the
594
- * failure that looks fine in testing. So it refuses.
595
- * See docs/spec/events.md § Permission scoping.
596
- */
597
- export class StackRelayScopeError extends Error {
598
- constructor(message) {
599
- super(message);
600
- this.name = 'StackRelayScopeError';
601
- }
602
- }
603
- /**
604
- * A resume cursor is opaque, but not arbitrary: it travels in an SSE `id:`
605
- * field, so a value spanning a line would truncate the frame carrying it.
606
- * Same charset and same reason as the auth nonce — see
607
- * docs/spec/change-feed.md § Frames.
608
- */
609
- const SEQ_FORMAT = /^[A-Za-z0-9_-]+$/;
610
- /**
611
- * `since` only means something where a relay exists: a stack with no
612
- * third party whose writes could have been missed has no cursor it could
613
- * ever have minted. Silently starting from the present would let the
614
- * caller believe it resumed when it did not, so a stack that cannot honor
615
- * `since` refuses it rather than ignoring it.
616
- *
617
- * Its shape is checked here rather than left to the adapter, so that a
618
- * malformed cursor is the same error whoever is underneath — the posture
619
- * query() already takes with a filter no adapter declared. The value stays
620
- * opaque: this asks whether it is framable, never what it means. See
621
- * docs/spec/events.md § Subscribing.
622
- */
623
- function assertSinceUsable(since, relaysChanges) {
624
- if (since === undefined)
625
- return;
626
- if (!relaysChanges) {
627
- throw new StackQueryError('subscribe() was passed `since`, but this stack relays no changes from elsewhere and so ' +
628
- 'has no cursor it could ever have minted. Omit `since` — or, for a stack that relays ' +
629
- 'from a server, use the seq off a previously delivered RecordChange.');
630
- }
631
- if (!SEQ_FORMAT.test(since)) {
632
- throw new StackQueryError(`subscribe() was passed the resume cursor "${since}", which is not a valid seq: a cursor ` +
633
- 'carries unreserved base64url characters only, because it travels in a frame id. Use ' +
634
- 'the seq off a previously delivered RecordChange, unaltered.');
635
- }
636
- }
637
- // -------------------------------------------------------
638
- // Record ID validation
639
- // -------------------------------------------------------
640
- const RESERVED_ID_PREFIX = '_';
641
- const DEFAULT_ID_TIMESTAMP_SKEW_MS = 24 * 60 * 60 * 1000;
642
36
  /**
643
37
  * Default grace period for Stack.collectAttachmentGarbage(), covering the
644
38
  * upload-then-associate window. See docs/spec/attachments.md § Garbage
@@ -646,179 +40,24 @@ const DEFAULT_ID_TIMESTAMP_SKEW_MS = 24 * 60 * 60 * 1000;
646
40
  */
647
41
  const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
648
42
  /**
649
- * Format and reserved-prefix checks — full-trust context (Stack.create()).
650
- * Checked before the format check: the Crockford charset already excludes
651
- * "_", so a reserved-looking id (e.g. "_config") would otherwise just fail
652
- * as a generic format error instead of a specific, actionable one.
653
- *
654
- * Throws StackQueryError, not StackValidationError: a malformed id is
655
- * structurally bad input the request never gets past — it doesn't reach
656
- * type-schema validation — the same reasoning that makes an undecodable
657
- * pagination cursor a StackQueryError rather than a content-validation
658
- * failure. See StackQueryError's doc comment.
659
- */
660
- /**
661
- * The same format rule applied to a `parentId` a caller names. Separate
662
- * from validateRecordId() only for its message: the id under discussion is
663
- * the destination, not the record being written, and a shared message would
664
- * report the wrong one. See docs/spec/data-model.md § Reparenting.
665
- */
666
- function validateParentId(parentId) {
667
- if (parentId.startsWith(RESERVED_ID_PREFIX)) {
668
- throw new StackQueryError(`Invalid parentId "${parentId}": uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
669
- }
670
- if (!isValidIdFormat(parentId)) {
671
- throw new StackQueryError(`Invalid parentId "${parentId}": expected 12 lowercase Crockford base-32 characters.`);
672
- }
673
- }
674
- function validateRecordId(id) {
675
- if (id.startsWith(RESERVED_ID_PREFIX)) {
676
- throw new StackQueryError(`ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
677
- }
678
- if (!isValidIdFormat(id)) {
679
- throw new StackQueryError(`Invalid ID "${id}": expected 12 lowercase Crockford base-32 characters.`);
680
- }
681
- }
682
- /**
683
- * Validity and range check for the backdating options on unscoped
684
- * Stack.create(). A Date is only as good as what the caller parsed it
685
- * from, and the two failure modes both need catching here rather than
686
- * downstream:
687
- *
688
- * - **Invalid Date** (`new Date('13/45/2020')` off a malformed import row)
689
- * has a NaN getTime(), and every comparison against NaN is false — so an
690
- * unchecked Invalid Date passes the updatedAt/createdAt ordering check
691
- * and the id/createdAt skew check by turning them off, mints the
692
- * epoch-zero ID `000000000xxx`, and persists a record whose
693
- * `createdAt.toISOString()` throws RangeError in serializeRecord() —
694
- * making that record, and any wire response containing it,
695
- * permanently unreadable.
696
- * - **Out of encodable range** — before 1970 crockford32Encode() throws a
697
- * bare RangeError from deep inside the ID encoder, and past
698
- * MAX_ID_TIMESTAMP the derived ID silently grows to 13 characters and
699
- * fails isValidIdFormat().
700
- *
701
- * Checked at the door for both, as a StackValidationError naming the
702
- * field. Applies whether or not an `id` is supplied: a clock field outside
703
- * this range is unrepresentable regardless of where the ID came from.
704
- */
705
- function validateClockField(value, path) {
706
- if (value === undefined)
707
- return [];
708
- // Type-checked callers always pass a Date, but this option is now
709
- // reachable from the wire: JSON has no Date, so a server that forwards a
710
- // parsed `POST /records` body hands us the ISO *string* it deserialized.
711
- // Without this the very next line is `"2020-…".getTime()` — an unhandled
712
- // TypeError, a 500 where the caller should have got a 400 naming the
713
- // field.
714
- if (!(value instanceof Date)) {
715
- return [{ path, message: `${path} must be a Date.` }];
716
- }
717
- const ms = value.getTime();
718
- if (Number.isNaN(ms)) {
719
- return [{ path, message: `${path} is not a valid Date.` }];
720
- }
721
- if (ms < 0 || ms > MAX_ID_TIMESTAMP) {
722
- return [
723
- {
724
- path,
725
- message: `${path} is outside the representable range ` +
726
- `(1970-01-01T00:00:00.000Z…${new Date(MAX_ID_TIMESTAMP).toISOString()}).`,
727
- },
728
- ];
729
- }
730
- return [];
731
- }
732
- /**
733
- * Timestamp-prefix plausibility check, shared by callers that compare an
734
- * ID's embedded millisecond against a different reference each:
735
- * ScopedStack.create() against the current time for any non-backdated
736
- * create (a grantee is untrusted and could otherwise mint an ID that
737
- * forges its sort position, and this is the one check standing between a
738
- * delegated or grantee caller and doing so), and Stack.create() — reached
739
- * directly when unscoped, or via ScopedStack.create() when the requester
740
- * is the owner acting alone with an explicit `createdAt` — against that
741
- * `createdAt` instead (the two must agree, not silently diverge — see
742
- * docs/spec/data-model.md § Record IDs). Pass null to disable.
743
- */
744
- function validateIdTimestampSkew(id, toleranceMs, referenceMs, referenceLabel) {
745
- if (toleranceMs === null)
746
- return;
747
- const skew = Math.abs(referenceMs - idTimestamp(id));
748
- if (skew > toleranceMs) {
749
- throw new StackValidationError([
750
- {
751
- path: 'id',
752
- message: `ID "${id}" timestamp disagrees with ${referenceLabel} by more than the allowed clock-skew tolerance (${toleranceMs}ms).`,
753
- },
754
- ]);
755
- }
756
- }
757
- // -------------------------------------------------------
758
- // Query helpers
759
- // -------------------------------------------------------
760
- /**
761
- * Walks `cursor` to exhaustion for the internal call sites that need every
762
- * match (grant checks, attachment cleanup) — query() itself always
763
- * paginates. Throws StackQueryError past `max` rather than silently
764
- * truncating a runaway scan. Not a public API.
765
- */
766
- async function queryAllPages(run, query, max = QUERY_ALL_MAX) {
767
- const records = [];
768
- let cursor = query.cursor;
769
- do {
770
- const page = await run({ ...query, cursor });
771
- records.push(...page.records);
772
- if (records.length > max) {
773
- throw new StackQueryError(`queryAllPages: exceeded max of ${max} records without exhausting the cursor`);
774
- }
775
- cursor = page.cursor ?? undefined;
776
- } while (cursor);
777
- return records;
778
- }
779
- /** Safety cap for queryAllPages() — generous for personal-stack scale. */
780
- const QUERY_ALL_MAX = 10_000;
781
- /**
782
- * Cursor-walks `run(query)` looking for the first record matching
783
- * `predicate`, short-circuiting on a match. Same bounded-scan discipline
784
- * as queryAllPages(): a match past page one is still found, and a
785
- * non-terminating scan throws StackQueryError.
43
+ * How far a change set's `parentId` will walk a proposed ancestor chain before refusing
44
+ * the move. Bounds the reads one call can cost; a hierarchy deeper than
45
+ * this is beyond what `parentId` is for. See docs/spec/data-model.md
46
+ * § Reparenting.
786
47
  */
787
- async function findFirstMatch(run, query, predicate, max = QUERY_ALL_MAX) {
788
- let cursor = query.cursor;
789
- let totalFetched = 0;
790
- do {
791
- const page = await run({ ...query, cursor });
792
- totalFetched += page.records.length;
793
- if (totalFetched > max) {
794
- throw new StackQueryError(`findFirstMatch: exceeded max of ${max} records without exhausting the cursor`);
795
- }
796
- for (const record of page.records) {
797
- if (await predicate(record))
798
- return record;
799
- }
800
- cursor = page.cursor ?? undefined;
801
- } while (cursor);
802
- return undefined;
803
- }
48
+ const MAX_PARENT_DEPTH = 64;
49
+ /** Sentinel: filter.baseId resolved to zero matching types. */
50
+ const EMPTY_FAMILY = Symbol('empty-family');
804
51
  /**
805
- * Shared implementation behind Stack.getEntityByDid() and
806
- * ScopedStack.getEntityByDid() — see docs/spec/identity.md § DID bindings
807
- * for the matching rules. `includeUnlisted` is a parameter rather than
808
- * hardcoded, so a caller not permitted to set it can omit it instead of
809
- * having query() throw.
52
+ * The `_attachment@1` fields a write may not move, each with the message it
53
+ * is refused by. Ordered as reported. See docs/spec/attachments.md § The
54
+ * `_attachment` record type.
810
55
  */
811
- async function lookupEntityByDid(run, did, canFilterContent, includeUnlisted) {
812
- const record = await findFirstMatch(run, {
813
- filter: {
814
- baseId: SYSTEM_TYPES.ENTITY,
815
- includeDeleted: true,
816
- ...(includeUnlisted && { includeUnlisted: true }),
817
- ...(canFilterContent && { content: { did } }),
818
- },
819
- }, (r) => r.content.did === did);
820
- return record ?? null;
821
- }
56
+ const ATTACHMENT_IMMUTABLE_FIELDS = [
57
+ ['mimeType', 'mimeType is immutable after creation; delete and re-upload to change it'],
58
+ ['fileId', 'fileId is immutable'],
59
+ ['size', 'size is immutable'],
60
+ ];
822
61
  // -------------------------------------------------------
823
62
  // Stack class
824
63
  // -------------------------------------------------------
@@ -854,7 +93,7 @@ export class Stack {
854
93
  /**
855
94
  * Announce a mutation that has already been persisted. Called after the
856
95
  * adapter write resolves and before the mutating method settles, so
857
- * `await stack.update(...)` guarantees subscribers have been notified —
96
+ * `await stack.mutate(...)` guarantees subscribers have been notified —
858
97
  * and nothing about work they deferred. A handler cannot fail the write:
859
98
  * there is nothing left to fail. See docs/spec/events.md § Handlers.
860
99
  */
@@ -984,7 +223,17 @@ export class Stack {
984
223
  const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
985
224
  if (parsed.version > priorMax)
986
225
  this.maxDefinedVersion.set(parsed.baseId, parsed.version);
987
- const nameErrors = validateSchemaFieldNames(schema);
226
+ // Shape first: the name checks and hashSchema() below both read the
227
+ // schema as a well-formed one, and a schema off the wire is parsed
228
+ // JSON that no compiler has seen.
229
+ const shapeErrors = validateSchemaShape(schema);
230
+ if (shapeErrors.length > 0) {
231
+ throw new StackValidationError(shapeErrors);
232
+ }
233
+ const nameErrors = [
234
+ ...validateSchemaReservedNames(schema),
235
+ ...validateSchemaFieldNames(schema),
236
+ ];
988
237
  if (nameErrors.length > 0) {
989
238
  throw new StackValidationError(nameErrors);
990
239
  }
@@ -1314,64 +563,141 @@ export class Stack {
1314
563
  return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
1315
564
  }
1316
565
  /**
1317
- * Update a record's content via JSON Merge Patch: omitted fields are
1318
- * kept, null removes a field. Validates the merged result against the
1319
- * record's *current* stored type and never changes typeId (see
1320
- * docs/spec/data-model.md § Type migrations); snapshots the prior state
1321
- * to version history. Associations and permissions have their own methods.
566
+ * Apply a change set — content patch, `parentId`, `permissions`,
567
+ * `associations`, `unlisted`, in any combination — as one atomic write
568
+ * producing one version and one snapshot. Keys are read for presence, so
569
+ * `unlisted: false` and `parentId: null` are changes.
570
+ *
571
+ * Every key present is checked against the record as it stands, and a
572
+ * change set already satisfied in all of them writes nothing and returns
573
+ * the record unchanged. The ops the change event carries are derived
574
+ * from that same comparison, so naming an aspect without moving it is
575
+ * never reported as moving it.
576
+ *
577
+ * Content is validated against the record's *current* stored type and
578
+ * `typeId` never changes (see docs/spec/data-model.md § Type
579
+ * migrations). See docs/spec/data-model.md § Mutations.
1322
580
  */
1323
- async update(id, content, opts = {}) {
581
+ async mutate(id, changes, opts = {}) {
1324
582
  this.assertOpen();
583
+ assertNonEmptyChangeSet(changes);
1325
584
  const existing = await this.adapter.getRecord(id);
1326
585
  if (!existing) {
1327
586
  throw new StackNotFoundError(`Record not found: "${id}"`);
1328
587
  }
1329
588
  this.checkIfVersion(existing, opts.ifVersion);
1330
- const type = await this.getTypeCached(existing.typeId);
1331
- if (!type) {
1332
- throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
1333
- }
1334
- // Checked on the raw patch, before the merge: a reserved key in a
1335
- // patch is lost by applyMergePatch rather than stored, and an
1336
- // `undefined` value is indistinguishable from an absent field once
1337
- // merged, so a check on the merged result would see neither. See
1338
- // validateReservedKeys() and validatePatchValues().
1339
- const patchErrors = [
1340
- ...validateReservedKeys(content),
1341
- ...validatePatchValues(content),
1342
- ...validateContentKeys(content),
589
+ const merged = await this.validateChangeSet(id, existing, changes);
590
+ const ops = changeSetOps(existing, changes, merged);
591
+ // Nothing moved, so there is nothing to version. Decided here rather
592
+ // than in the adapter: "did this change anything" is a question about
593
+ // the record's meaning, and an adapter is never handed a write that
594
+ // writes nothing.
595
+ if (ops.length === 0)
596
+ return existing;
597
+ const previousParentId = existing.parentId ?? null;
598
+ const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), this.writeOptions(existing, opts));
599
+ this.emitChange(ops, updated, ops.includes('reparent') ? { previousParentId } : {});
600
+ return updated;
601
+ }
602
+ /**
603
+ * mutate() with `contentPatch` alone. The common case by a wide margin,
604
+ * and named for what it does rather than for a symmetry with create()
605
+ * that a patch does not have.
606
+ */
607
+ async patchContent(id, patch, opts = {}) {
608
+ return this.mutate(id, { contentPatch: patch }, opts);
609
+ }
610
+ /**
611
+ * Every check a change set owes before anything is written, in one pass:
612
+ * one refused key refuses the call, so none of them may run after a
613
+ * partial write. Returns the merged content when the set carries a
614
+ * content patch, since the caller needs it to decide whether content
615
+ * actually moved.
616
+ *
617
+ * Parent existence and acyclicity are asked only when the destination
618
+ * differs from where the record already sits — a move to where it
619
+ * already is names no new edge, and would otherwise cost a read apiece.
620
+ */
621
+ async validateChangeSet(id, existing, changes) {
622
+ const { contentPatch, permissions, associations, parentId } = changes;
623
+ const errors = [
624
+ ...(contentPatch
625
+ ? [
626
+ ...validateReservedKeys(contentPatch),
627
+ ...validatePatchValues(contentPatch),
628
+ ...validateContentKeys(contentPatch),
629
+ ]
630
+ : []),
631
+ ...(permissions ? validatePermissions(permissions) : []),
632
+ ...(associations ? validateAssociations(associations) : []),
1343
633
  ];
1344
- if (patchErrors.length > 0) {
1345
- throw new StackValidationError(patchErrors);
1346
- }
1347
- // The patch is what travels, so the patch is what's measured — a small
1348
- // patch against a large record is not an oversized request.
1349
- assertContentSize(content, this.features.limits.contentBytes, 'Patch');
1350
- // Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
1351
- const merged = applyMergePatch(existing.content, content);
1352
- const errors = validateContent(merged, type.schema);
1353
- if (errors.length > 0) {
634
+ if (errors.length > 0)
1354
635
  throw new StackValidationError(errors);
636
+ let merged;
637
+ if (contentPatch) {
638
+ // The patch is what travels, so the patch is what's measured — a
639
+ // small patch against a large record is not an oversized request.
640
+ assertContentSize(contentPatch, this.features.limits.contentBytes, 'Patch');
641
+ const type = await this.getTypeCached(existing.typeId);
642
+ if (!type) {
643
+ throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
644
+ }
645
+ merged = applyMergePatch(existing.content, contentPatch);
646
+ const contentErrors = validateContent(merged, type.schema);
647
+ if (contentErrors.length > 0)
648
+ throw new StackValidationError(contentErrors);
649
+ if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
650
+ // Presence decides mimeType and value decides the rest: re-sending
651
+ // the mimeType a record already holds is refused outright, while a
652
+ // client round-tripping fileId or size unchanged has claimed nothing.
653
+ this.assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(contentPatch, field) &&
654
+ (field === 'mimeType' ||
655
+ merged[field] !==
656
+ existing.content[field]));
657
+ }
658
+ await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged);
659
+ if (id === SYSTEM_TYPES.CONFIG) {
660
+ this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
661
+ }
1355
662
  }
1356
- if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
1357
- this.checkAttachmentImmutableFields(content, existing.content, merged);
663
+ if (associations !== undefined) {
664
+ this.assertGroupAdminRemains(existing, associations);
1358
665
  }
1359
- await this.checkBindingsOnUpdate(existing.typeId, id, content, existing.content, merged);
1360
- if (id === SYSTEM_TYPES.CONFIG) {
1361
- this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
666
+ if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
667
+ await this.assertParentExists(id, parentId);
668
+ await this.assertNoParentCycle(id, parentId);
1362
669
  }
1363
- const updated = await this.adapter.patchContent(id, content, {
1364
- expectedVersion: opts.ifVersion,
1365
- snapshot: this.buildVersionSnapshot(existing),
1366
- updatedBy: opts.updatedBy,
1367
- updatedVia: opts.updatedVia,
1368
- });
1369
- this.emitChange('update', updated);
1370
- return updated;
670
+ return merged;
671
+ }
672
+ /**
673
+ * Refuse a write that would leave a `_group` Record with no `admin` on
674
+ * its roster. Asked of the roster the write would *produce* — the one
675
+ * place a change-set check reads the post-state rather than the
676
+ * pre-state, because the rule is about what is left behind rather than
677
+ * what the caller named.
678
+ *
679
+ * That framing is the whole of the self-removal question: an `admin`
680
+ * removing themselves passes while another remains and is refused when
681
+ * they are the last, and neither case needs to know whose entry went.
682
+ *
683
+ * Lives here rather than in `ScopedStack` deliberately. Who may write a
684
+ * `_group` is a permission question and belongs to the gate up there;
685
+ * this is an integrity constraint on the Record, so it binds every
686
+ * requester — the stack owner included, who would otherwise be the hole
687
+ * that stops anything downstream relying on the invariant.
688
+ * See docs/spec/identity.md § Group.
689
+ */
690
+ assertGroupAdminRemains(record, next) {
691
+ if (!isGroupRecord(record))
692
+ return;
693
+ if (hasGroupAdmin(next))
694
+ return;
695
+ throw new StackConflictError(`Cannot leave group "${record.id}" without an admin: a _group record's roster keeps at ` +
696
+ 'least one `admin` relationship association. Name the incoming admin in the same write.');
1371
697
  }
1372
698
  /**
1373
699
  * Add an association to a record. Snapshots the record's prior state and
1374
- * bumps version, same as update() — associations are covered by the same
700
+ * bumps version, same as patchContent() — associations are covered by the same
1375
701
  * versioning rule as content.
1376
702
  * If the association already exists (same kind, label, and payload), this
1377
703
  * is a no-op. Returns the record as it now stands — unchanged on a no-op.
@@ -1389,12 +715,7 @@ export class Stack {
1389
715
  if ((existing.associations ?? []).some((a) => associationEqual(a, association))) {
1390
716
  return existing;
1391
717
  }
1392
- const updated = await this.adapter.associate(id, association, {
1393
- expectedVersion: opts.ifVersion,
1394
- snapshot: this.buildVersionSnapshot(existing),
1395
- updatedBy: opts.updatedBy,
1396
- updatedVia: opts.updatedVia,
1397
- });
718
+ const updated = await this.adapter.associate(id, association, this.writeOptions(existing, opts));
1398
719
  this.emitChange('associate', updated);
1399
720
  return updated;
1400
721
  }
@@ -1416,113 +737,39 @@ export class Stack {
1416
737
  if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) {
1417
738
  return existing;
1418
739
  }
1419
- const updated = await this.adapter.dissociate(id, association, {
1420
- expectedVersion: opts.ifVersion,
1421
- snapshot: this.buildVersionSnapshot(existing),
1422
- updatedBy: opts.updatedBy,
1423
- updatedVia: opts.updatedVia,
1424
- });
1425
- this.emitChange('dissociate', updated);
1426
- return updated;
1427
- }
1428
- /**
1429
- * Replace all permissions on a record. Snapshots and bumps version, same
1430
- * as associate(). Pass an empty array to make the record private (the
1431
- * default). No-op if the new set is deep-equal to the current one. Returns
1432
- * the record as it now stands — unchanged on a no-op.
1433
- */
1434
- async setPermissions(id, permissions, opts = {}) {
1435
- this.assertOpen();
1436
- const errors = validatePermissions(permissions);
1437
- if (errors.length > 0) {
1438
- throw new StackValidationError(errors);
1439
- }
1440
- const existing = await this.adapter.getRecord(id);
1441
- if (!existing) {
1442
- throw new StackNotFoundError(`Record not found: "${id}"`);
740
+ // Removing anything else cannot take the roster to zero, so only an
741
+ // admin entry is worth deriving the post-state for. Derived rather than
742
+ // counted so this path and the change set's reach the invariant through
743
+ // the same helper, asking the same question of the same shape of list.
744
+ if (isGroupAdminAssociation(association)) {
745
+ this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
1443
746
  }
1444
- this.checkIfVersion(existing, opts.ifVersion);
1445
- if (permissionsEqual(existing.permissions ?? [], permissions))
1446
- return existing;
1447
- const updated = await this.adapter.setPermissions(id, permissions, {
1448
- expectedVersion: opts.ifVersion,
1449
- snapshot: this.buildVersionSnapshot(existing),
1450
- updatedBy: opts.updatedBy,
1451
- updatedVia: opts.updatedVia,
1452
- });
1453
- this.emitChange('permissions', updated);
747
+ const updated = await this.adapter.dissociate(id, association, this.writeOptions(existing, opts));
748
+ this.emitChange('dissociate', updated);
1454
749
  return updated;
1455
750
  }
1456
751
  /**
1457
- * Withhold a record from enumeration, or restore it. Orthogonal to
1458
- * setPermissions(): it says nothing about who may read the record, only
1459
- * whether `query()` and the change feed enumerate it by default. No-op if
1460
- * already in the requested state. Returns the record as it now stands —
1461
- * unchanged on a no-op. See docs/spec/access-control.md §
1462
- * Unlisted records.
752
+ * The checks a *caller-named* destination owes, before the cycle walk:
753
+ * `parentId` is a real, well-formed record id. Format first, so a
754
+ * malformed one is a 400 naming the problem rather than a read that
755
+ * cannot match. Existence closes the gap between the owner path and a
756
+ * non-owner's, where canReadReferent() already refuses a parent that
757
+ * isn't there.
1463
758
  *
1464
- * The op passed to emitChange() carries the transition direction —
1465
- * `unlist` (kind `deleted`, so subscribers already holding the record are
1466
- * told to drop it) or `list` (kind `changed`, an upsert like `undelete`,
1467
- * for the record's publish moment).
759
+ * restoreVersion() deliberately does not call this — see its own comment.
760
+ * See docs/spec/data-model.md § Reparenting.
1468
761
  */
1469
- async setUnlisted(id, unlisted, opts = {}) {
1470
- this.assertOpen();
1471
- const existing = await this.adapter.getRecord(id);
1472
- if (!existing) {
1473
- throw new StackNotFoundError(`Record not found: "${id}"`);
1474
- }
1475
- this.checkIfVersion(existing, opts.ifVersion);
1476
- if (Boolean(existing.unlistedAt) === unlisted)
1477
- return existing;
1478
- const updated = await this.adapter.setUnlisted(id, unlisted, {
1479
- expectedVersion: opts.ifVersion,
1480
- snapshot: this.buildVersionSnapshot(existing),
1481
- updatedBy: opts.updatedBy,
1482
- updatedVia: opts.updatedVia,
1483
- });
1484
- this.emitChange(unlisted ? 'unlist' : 'list', updated);
1485
- return updated;
1486
- }
1487
- /**
1488
- * Move a record into another container, or to the root with `null`.
1489
- * Snapshots and bumps version, same as setUnlisted(). No-op if the
1490
- * record is already there. Returns the record as it now stands —
1491
- * unchanged on a no-op.
1492
- *
1493
- * `parentId` is the one native field a write-holder may move after
1494
- * creation, and it confers nothing: containment is not an
1495
- * access-control edge, so a move changes which queries and feeds
1496
- * enumerate a record and nothing about who may read it. See
1497
- * docs/spec/data-model.md § Reparenting.
1498
- */
1499
- async setParent(id, parentId, opts = {}) {
1500
- this.assertOpen();
1501
- const existing = await this.adapter.getRecord(id);
1502
- if (!existing) {
1503
- throw new StackNotFoundError(`Record not found: "${id}"`);
1504
- }
1505
- this.checkIfVersion(existing, opts.ifVersion);
1506
- const previousParentId = existing.parentId ?? null;
1507
- if (previousParentId === parentId)
1508
- return existing;
1509
- if (parentId !== null) {
1510
- await this.assertParentExists(id, parentId);
1511
- await this.assertNoParentCycle(id, parentId);
762
+ async assertParentExists(id, parentId) {
763
+ validateParentId(parentId);
764
+ if (!(await this.adapter.getRecord(parentId))) {
765
+ throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
766
+ 'exist when it is named.');
1512
767
  }
1513
- const updated = await this.adapter.setParent(id, parentId, {
1514
- expectedVersion: opts.ifVersion,
1515
- snapshot: this.buildVersionSnapshot(existing),
1516
- updatedBy: opts.updatedBy,
1517
- updatedVia: opts.updatedVia,
1518
- });
1519
- this.emitChange('reparent', updated, { previousParentId });
1520
- return updated;
1521
768
  }
1522
769
  /**
1523
770
  * Refuse an edge that would make a record its own ancestor — asked at
1524
- * every site that adds one: setParent(), a restore putting a container
1525
- * back, and a create naming both its own id and a parent. Nothing
771
+ * every site that adds one: a change set naming `parentId`, a restore
772
+ * putting a container back, and a create naming both its own id and a parent. Nothing
1526
773
  * downstream (a generator deriving a page path, a folder view) is
1527
774
  * written to survive a cycle.
1528
775
  *
@@ -1540,24 +787,6 @@ export class Stack {
1540
787
  * the same posture applied here.
1541
788
  * See docs/spec/data-model.md § Reparenting.
1542
789
  */
1543
- /**
1544
- * The checks a *caller-named* destination owes, before the cycle walk:
1545
- * `parentId` is a real, well-formed record id. Format first, so a
1546
- * malformed one is a 400 naming the problem rather than a read that
1547
- * cannot match. Existence closes the gap between the owner path and a
1548
- * non-owner's, where canReadReferent() already refuses a parent that
1549
- * isn't there.
1550
- *
1551
- * restoreVersion() deliberately does not call this — see its own comment.
1552
- * See docs/spec/data-model.md § Reparenting.
1553
- */
1554
- async assertParentExists(id, parentId) {
1555
- validateParentId(parentId);
1556
- if (!(await this.adapter.getRecord(parentId))) {
1557
- throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
1558
- 'exist when it is named.');
1559
- }
1560
- }
1561
790
  async assertNoParentCycle(id, parentId) {
1562
791
  let cursor = parentId;
1563
792
  for (let depth = 0; cursor !== undefined; depth++) {
@@ -1606,12 +835,7 @@ export class Stack {
1606
835
  this.checkIfVersion(existing, opts.ifVersion);
1607
836
  if (existing.deletedAt)
1608
837
  return;
1609
- const deleted = await this.adapter.deleteRecord(id, {
1610
- expectedVersion: opts.ifVersion,
1611
- snapshot: this.buildVersionSnapshot(existing),
1612
- updatedBy: opts.updatedBy,
1613
- updatedVia: opts.updatedVia,
1614
- });
838
+ const deleted = await this.adapter.deleteRecord(id, this.writeOptions(existing, opts));
1615
839
  if (deleted)
1616
840
  this.emitChange('delete', deleted);
1617
841
  }
@@ -1630,12 +854,7 @@ export class Stack {
1630
854
  this.checkIfVersion(existing, opts.ifVersion);
1631
855
  if (!existing.deletedAt)
1632
856
  return existing;
1633
- const undeleted = await this.adapter.undeleteRecord(id, {
1634
- expectedVersion: opts.ifVersion,
1635
- snapshot: this.buildVersionSnapshot(existing),
1636
- updatedBy: opts.updatedBy,
1637
- updatedVia: opts.updatedVia,
1638
- });
857
+ const undeleted = await this.adapter.undeleteRecord(id, this.writeOptions(existing, opts));
1639
858
  this.emitChange('undelete', undeleted);
1640
859
  return undeleted;
1641
860
  }
@@ -1728,7 +947,7 @@ export class Stack {
1728
947
  this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
1729
948
  }
1730
949
  // Restoring is a write like any other, so it owes the same immutability
1731
- // check update() pays — a snapshot taken before a card claimed its DID
950
+ // check a content patch pays — a snapshot taken before a card claimed its DID
1732
951
  // would otherwise move the binding by rolling content back. Uniqueness
1733
952
  // needs no separate check here: a restore can only put back a value this
1734
953
  // same card already held, which immutability already refuses to change.
@@ -1736,7 +955,7 @@ export class Stack {
1736
955
  this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
1737
956
  }
1738
957
  // A restore that puts a container back is an edge-adding site like
1739
- // setParent(), and the chain above that container may have moved since
958
+ // a change set's `parentId`, and the chain above that container may have moved since
1740
959
  // the snapshot was taken. The cycle walk therefore applies — but
1741
960
  // assertParentExists() deliberately does not: a restore is not a caller
1742
961
  // naming a destination, it is history being put back, and the container
@@ -1751,11 +970,11 @@ export class Stack {
1751
970
  if (moves && targetParentId !== null) {
1752
971
  await this.assertNoParentCycle(id, targetParentId);
1753
972
  }
973
+ // A `_group`'s roster is authority, not data: it does not roll back.
974
+ // See docs/spec/versioning.md § Restore semantics.
1754
975
  const restored = await this.adapter.restoreVersion(id, version, {
1755
- expectedVersion: opts.ifVersion,
1756
- snapshot: this.buildVersionSnapshot(existing),
1757
- updatedBy: opts.updatedBy,
1758
- updatedVia: opts.updatedVia,
976
+ ...this.writeOptions(existing, opts),
977
+ ...(isGroupRecord(existing) && { restoreAssociations: false }),
1759
978
  });
1760
979
  this.emitChange('restore', restored, moves ? { previousParentId } : {});
1761
980
  return restored;
@@ -1763,12 +982,12 @@ export class Stack {
1763
982
  /**
1764
983
  * Commit a per-record migration: replace `content` and `typeId` together
1765
984
  * in one step, validated against `toTypeId`'s schema exactly as
1766
- * create()/update() validate against a type's schema. The single-record
985
+ * create()/mutate() validate against a type's schema. The single-record
1767
986
  * counterpart to migrateAll() — content here is supplied by the caller
1768
987
  * (computed client-side by the type's owning app, per
1769
988
  * docs/spec/wire-format.md § Migration commit) rather than a registered
1770
989
  * Migration function. Snapshots the prior state to version history, same
1771
- * as update()/restoreVersion(), and takes the same optional `ifVersion`
990
+ * as mutate()/restoreVersion(), and takes the same optional `ifVersion`
1772
991
  * precondition every version-bumping mutation takes — checked atomically
1773
992
  * at the adapter, not here (see docs/spec/versioning.md § Optimistic
1774
993
  * concurrency).
@@ -1778,7 +997,7 @@ export class Stack {
1778
997
  * record as it stands, so it owes both sets of integrity checks — the
1779
998
  * binding rules, the attachment rules, and `_config`'s. Missing either
1780
999
  * half would make migrate a second, unguarded write path to the same
1781
- * state create()/update() refuse to reach.
1000
+ * state create()/mutate() refuse to reach.
1782
1001
  */
1783
1002
  async commitMigration(id, toTypeId, content, opts = {}) {
1784
1003
  this.assertOpen();
@@ -1834,7 +1053,10 @@ export class Stack {
1834
1053
  'Create the group instead.');
1835
1054
  }
1836
1055
  if (fromFamily === SYSTEM_TYPES.ATTACHMENT) {
1837
- this.checkAttachmentImmutableOnMigrate(existingContent, content);
1056
+ // Value-wise throughout: a migration re-sends all three required
1057
+ // fields, so presence would refuse every migration of the family.
1058
+ this.assertAttachmentImmutable((field) => content[field] !==
1059
+ existingContent[field]);
1838
1060
  }
1839
1061
  else if (toFamily === SYSTEM_TYPES.ATTACHMENT) {
1840
1062
  // A record arriving from outside the family stakes a fresh claim on
@@ -1845,12 +1067,7 @@ export class Stack {
1845
1067
  if (id === SYSTEM_TYPES.CONFIG) {
1846
1068
  this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
1847
1069
  }
1848
- const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
1849
- expectedVersion: opts.ifVersion,
1850
- snapshot: this.buildVersionSnapshot(existing),
1851
- updatedBy: opts.updatedBy,
1852
- updatedVia: opts.updatedVia,
1853
- });
1070
+ const migrated = await this.adapter.commitMigration(id, toTypeId, content, this.writeOptions(existing, opts));
1854
1071
  this.emitChange('migrate', migrated);
1855
1072
  return migrated;
1856
1073
  }
@@ -1864,7 +1081,7 @@ export class Stack {
1864
1081
  /**
1865
1082
  * Immutability for every binding field a patch touches, then uniqueness
1866
1083
  * for the subset that carries it. Fields absent from the patch carry no
1867
- * new claim — update() is a merge, so an untouched binding is the one the
1084
+ * new claim — a content patch is a merge, so an untouched binding is the one the
1868
1085
  * card already holds.
1869
1086
  */
1870
1087
  async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
@@ -2000,62 +1217,26 @@ export class Stack {
2000
1217
  }
2001
1218
  }
2002
1219
  /**
2003
- * filename is the only mutable field on an _attachment@1 record; fileId,
2004
- * size, and mimeType are immutable (even a same-value mimeType rewrite
2005
- * is refused). The correction flow is delete + re-upload. See
2006
- * docs/spec/attachments.md § The `_attachment` record type.
2007
- */
2008
- checkAttachmentImmutableFields(patch, existing, merged) {
2009
- const errors = [];
2010
- if (Object.prototype.hasOwnProperty.call(patch, 'mimeType')) {
2011
- errors.push({
2012
- path: 'mimeType',
2013
- message: 'mimeType is immutable after creation; delete and re-upload to change it',
2014
- });
2015
- }
2016
- if (Object.prototype.hasOwnProperty.call(patch, 'fileId') &&
2017
- merged.fileId !== existing.fileId) {
2018
- errors.push({ path: 'fileId', message: 'fileId is immutable' });
2019
- }
2020
- if (Object.prototype.hasOwnProperty.call(patch, 'size') && merged.size !== existing.size) {
2021
- errors.push({ path: 'size', message: 'size is immutable' });
2022
- }
2023
- if (errors.length > 0) {
2024
- throw new StackValidationError(errors);
2025
- }
2026
- }
2027
- /**
2028
- * The same immutability checkAttachmentImmutableFields() enforces, asked
2029
- * value-wise instead of presence-wise: a migration replaces content
2030
- * wholesale, so it necessarily re-sends `mimeType`, `fileId` and `size`
2031
- * (all required) and a presence check would refuse every migration. Only
2032
- * an actual change is a violation.
1220
+ * filename is the only mutable field on an `_attachment@1` record; fileId,
1221
+ * size and mimeType are immutable, and the correction flow is delete +
1222
+ * re-upload. `violates` decides what counts as touching one, because the
1223
+ * two write shapes disagree: a patch names only what it changes, while a
1224
+ * migration replaces content wholesale and necessarily re-sends all three.
2033
1225
  *
2034
1226
  * Repointing `fileId` is the one that matters most: an `_attachment@1`
2035
1227
  * record naming a fileId is what canAccessFile()'s uploader clause reads,
2036
1228
  * so moving an existing record onto another file's hash is a route to
2037
1229
  * bytes the record's author never uploaded.
1230
+ * See docs/spec/attachments.md § The `_attachment` record type.
2038
1231
  */
2039
- checkAttachmentImmutableOnMigrate(existing, next) {
2040
- const errors = [];
2041
- if (next.mimeType !== existing.mimeType) {
2042
- errors.push({
2043
- path: 'mimeType',
2044
- message: 'mimeType is immutable after creation; delete and re-upload to change it',
2045
- });
2046
- }
2047
- if (next.fileId !== existing.fileId) {
2048
- errors.push({ path: 'fileId', message: 'fileId is immutable' });
2049
- }
2050
- if (next.size !== existing.size) {
2051
- errors.push({ path: 'size', message: 'size is immutable' });
2052
- }
1232
+ assertAttachmentImmutable(violates) {
1233
+ const errors = ATTACHMENT_IMMUTABLE_FIELDS.filter(([field]) => violates(field)).map(([path, message]) => ({ path, message }));
2053
1234
  if (errors.length > 0) {
2054
1235
  throw new StackValidationError(errors);
2055
1236
  }
2056
1237
  }
2057
1238
  /**
2058
- * `_config.entityId` defines stack ownership; neither update() nor
1239
+ * `_config.entityId` defines stack ownership; neither patchContent() nor
2059
1240
  * restoreVersion() may change it. A conflict with stack integrity, not a
2060
1241
  * schema violation — hence StackConflictError. See docs/spec.md § The
2061
1242
  * `_config` record.
@@ -2260,12 +1441,6 @@ export class Stack {
2260
1441
  // -------------------------------------------------------
2261
1442
  // Lifecycle
2262
1443
  // -------------------------------------------------------
2263
- /**
2264
- * Flush pending writes to the underlying storage. A no-op for adapters
2265
- * that commit on every call (SQLite, the API adapter); meaningful for
2266
- * ones that buffer, and for checkpointing a stack that stays open —
2267
- * close() covers the teardown case on its own.
2268
- */
2269
1444
  /**
2270
1445
  * Observe every change made through this Stack. Unscoped, so no
2271
1446
  * permission filter applies — a caller holding a `Stack` already reaches
@@ -2330,6 +1505,12 @@ export class Stack {
2330
1505
  get relaysChanges() {
2331
1506
  return typeof this.adapter.subscribeChanges === 'function';
2332
1507
  }
1508
+ /**
1509
+ * Flush pending writes to the underlying storage. A no-op for adapters
1510
+ * that commit on every call (SQLite, the API adapter); meaningful for
1511
+ * ones that buffer, and for checkpointing a stack that stays open —
1512
+ * close() covers the teardown case on its own.
1513
+ */
2333
1514
  async flush() {
2334
1515
  this.assertOpen();
2335
1516
  await this.adapter.flush?.();
@@ -2410,9 +1591,7 @@ export class Stack {
2410
1591
  this.assertOpen();
2411
1592
  if (target !== undefined)
2412
1593
  validateGrantTarget(target);
2413
- const all = await queryAllPages((q) => this.query(q), {
2414
- filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2415
- });
1594
+ const all = await loadGrantRecords((q) => this.query(q));
2416
1595
  if (target === undefined)
2417
1596
  return all;
2418
1597
  if (target === null || typeof target === 'object') {
@@ -2445,9 +1624,7 @@ export class Stack {
2445
1624
  async revoke(target, grants) {
2446
1625
  this.assertOpen();
2447
1626
  validateGrantTarget(target);
2448
- const all = await queryAllPages((q) => this.query(q), {
2449
- filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2450
- });
1627
+ const all = await loadGrantRecords((q) => this.query(q));
2451
1628
  for (const g of grants) {
2452
1629
  const familyId = baseIdOf(g.typeId);
2453
1630
  const actionSet = new Set(g.actions);
@@ -2567,6 +1744,22 @@ export class Stack {
2567
1744
  return;
2568
1745
  throw new StackVersionConflictError(`Record "${existing.id}" is at version ${existing.version}, expected ${ifVersion}`, existing.id, ifVersion, existing.version);
2569
1746
  }
1747
+ /**
1748
+ * The options every version-bumping adapter write carries: the `ifVersion`
1749
+ * precondition, the prior-state snapshot that has to land in the same
1750
+ * atomic write, and who to attribute the change to. Taken together so a
1751
+ * new mutating verb cannot quietly omit one — `updatedVia` most of all,
1752
+ * whose absence reads as an undelegated write.
1753
+ * See docs/spec/versioning.md § Version history.
1754
+ */
1755
+ writeOptions(existing, opts) {
1756
+ return {
1757
+ expectedVersion: opts.ifVersion,
1758
+ snapshot: this.buildVersionSnapshot(existing),
1759
+ updatedBy: opts.updatedBy,
1760
+ updatedVia: opts.updatedVia,
1761
+ };
1762
+ }
2570
1763
  /**
2571
1764
  * Snapshot of a record's prior state, passed with the mutating adapter
2572
1765
  * call so snapshot and mutation land in one atomic write. `associations`
@@ -2590,1248 +1783,4 @@ export class Stack {
2590
1783
  };
2591
1784
  }
2592
1785
  }
2593
- // -------------------------------------------------------
2594
- // Equality helpers
2595
- // -------------------------------------------------------
2596
- /**
2597
- * Matches the SQLite adapter's association primary key (kind, label,
2598
- * file_id, related_scope, related_id, related_ns, related_stack).
2599
- */
2600
- function associationEqual(a, b) {
2601
- if (a.kind !== b.kind || a.label !== b.label)
2602
- return false;
2603
- if (a.kind === 'attachment' && b.kind === 'attachment')
2604
- return a.fileId === b.fileId;
2605
- if (a.kind === 'relationship' && b.kind === 'relationship') {
2606
- return targetEqual(a.target, b.target);
2607
- }
2608
- return true;
2609
- }
2610
- /** Structural equality per target arm — what dissociate() matches on. */
2611
- export function targetEqual(a, b) {
2612
- if (a.scope !== b.scope)
2613
- return false;
2614
- if (a.scope === 'record' && b.scope === 'record') {
2615
- return a.recordId === b.recordId && (a.stackUrl ?? '') === (b.stackUrl ?? '');
2616
- }
2617
- if (a.scope === 'entity' && b.scope === 'entity')
2618
- return a.entityId === b.entityId;
2619
- if (a.scope === 'external' && b.scope === 'external')
2620
- return a.ns === b.ns && a.id === b.id;
2621
- return false;
2622
- }
2623
- function permissionEqual(a, b) {
2624
- if (a.access !== b.access)
2625
- return false;
2626
- if (a.access === 'public')
2627
- return true;
2628
- if (a.access === 'entity' && b.access === 'entity') {
2629
- return a.entityId === b.entityId && a.read === b.read && a.write === b.write;
2630
- }
2631
- if (a.access === 'group' && b.access === 'group') {
2632
- return a.groupId === b.groupId && a.role === b.role && a.read === b.read && a.write === b.write;
2633
- }
2634
- return false;
2635
- }
2636
- function permissionsEqual(a, b) {
2637
- return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
2638
- }
2639
- // -------------------------------------------------------
2640
- // ScopedStack
2641
- // -------------------------------------------------------
2642
- /** Default page size used to fill a permission-filtered query result. */
2643
- const DEFAULT_QUERY_LIMIT = 50;
2644
- const MAX_QUERY_LIMIT = 1000;
2645
- /**
2646
- * How far setParent() will walk a proposed ancestor chain before refusing
2647
- * the move. Bounds the reads one call can cost; a hierarchy deeper than
2648
- * this is beyond what `parentId` is for. See docs/spec/data-model.md
2649
- * § Reparenting.
2650
- */
2651
- const MAX_PARENT_DEPTH = 64;
2652
- /**
2653
- * Ensures `creator` carries an `admin` relationship association, adding one
2654
- * if it's not already present. Used to bootstrap a `_group` record's first
2655
- * admin at create time.
2656
- */
2657
- function stampGroupAdmin(associations, creator) {
2658
- const list = associations ?? [];
2659
- const alreadyAdmin = list.some((a) => a.kind === 'relationship' &&
2660
- a.label === 'admin' &&
2661
- a.target.scope === 'entity' &&
2662
- a.target.entityId === creator);
2663
- if (alreadyAdmin)
2664
- return list;
2665
- return [
2666
- ...list,
2667
- { kind: 'relationship', label: 'admin', target: { scope: 'entity', entityId: creator } },
2668
- ];
2669
- }
2670
- /**
2671
- * Drops a snapshot's `permissions` — owner-only audit data, never served
2672
- * to a non-owner history reader. `entityId` (change attribution) stays.
2673
- * See docs/spec/versioning.md § History access.
2674
- */
2675
- function stripVersionPermissions(version) {
2676
- if (version.permissions === undefined)
2677
- return version;
2678
- const { permissions: _permissions, ...rest } = version;
2679
- return rest;
2680
- }
2681
- /**
2682
- * The tombstone a soft-deleted Record is presented as. `permissions` is
2683
- * retained because it decides whether the caller may undelete; history is
2684
- * exempt, which is why getVersions() still serves the content this
2685
- * withholds. See docs/spec/versioning.md § The tombstone is literal.
2686
- */
2687
- function tombstoneOf(record) {
2688
- return {
2689
- id: record.id,
2690
- typeId: record.typeId,
2691
- createdAt: record.createdAt,
2692
- updatedAt: record.updatedAt,
2693
- version: record.version,
2694
- content: {},
2695
- ...(record.deletedAt !== undefined && { deletedAt: record.deletedAt }),
2696
- ...(record.unlistedAt !== undefined && { unlistedAt: record.unlistedAt }),
2697
- ...(record.permissions !== undefined && { permissions: record.permissions }),
2698
- };
2699
- }
2700
- /** A soft-deleted Record presented as its tombstone; anything else untouched. */
2701
- const presentDeleted = (record) => record.deletedAt ? tombstoneOf(record) : record;
2702
- /**
2703
- * A permission-enforcing view of a Stack for a single (principal, subject)
2704
- * pair, obtained via `stack.asEntity(entityId)`. A record the request
2705
- * cannot read answers exactly as a missing one does — null on reads,
2706
- * StackNotFoundError on the verbs that name one — so only a requester who
2707
- * could have read it is told a refusal was about access.
2708
- * See docs/spec/access-control.md § Errors and information exposure.
2709
- *
2710
- * Two identities, one rule: **the principal governs authority, the subject
2711
- * governs attribution.** Grant lookup and the privilege-bearing gates that
2712
- * no grant reaches (setPermissions, group management, hard delete, widening
2713
- * access at create time) key on `principalEntityId`; authorship, `-own`
2714
- * matching, record-level permission resolution, and "files I uploaded"
2715
- * lookups key on `subjectEntityId`.
2716
- *
2717
- * Unconditional owner access follows that same split rather than one
2718
- * identity: it answers *what data is reachable* for the subject (an owner
2719
- * subject resolves past every permission check) and *who may exercise a
2720
- * privileged verb* for the principal (an owner app is not bounded by
2721
- * grants). Under delegation both halves apply, and a mistake on the
2722
- * authority side is an escalation rather than a preference.
2723
- */
2724
- /**
2725
- * The authority lookups canRead needs, held for the life of one
2726
- * subscription. A subscription is long-lived where a query is not, so the
2727
- * cache is only safe because every write that can change canRead's answer
2728
- * arrives as an event that drops it — see ScopedSubscription.
2729
- */
2730
- class FeedAuthorityCache {
2731
- grantRecords = null;
2732
- /**
2733
- * Bumped by every invalidation, so a load that was already in flight can
2734
- * tell that its result is stale before seating it.
2735
- */
2736
- generation = 0;
2737
- /** Roster roles, memoized per group, as ScopedStack.query() does per query. */
2738
- roles = new Map();
2739
- /** Every `_grant` record, refilled through `load` after an invalidation. */
2740
- async grants(load) {
2741
- if (this.grantRecords !== null)
2742
- return this.grantRecords;
2743
- const generation = this.generation;
2744
- const loaded = await load();
2745
- // An invalidation during the load already dropped the set these
2746
- // replace, so seating them would outlive the write that expired them
2747
- // and no later event would drop them again. The event being decided
2748
- // precedes that write, so it is still decided on what was loaded.
2749
- if (this.generation === generation)
2750
- this.grantRecords = loaded;
2751
- return loaded;
2752
- }
2753
- invalidateFor(typeFamily) {
2754
- if (typeFamily === SYSTEM_TYPES.GRANT) {
2755
- this.grantRecords = null;
2756
- this.generation++;
2757
- }
2758
- if (typeFamily === SYSTEM_TYPES.GROUP)
2759
- this.roles = new Map();
2760
- }
2761
- }
2762
- /**
2763
- * A ScopedStack's delivery: canRead per event, with the grant and roster
2764
- * lookups it needs cached for the life of the subscription and dropped the
2765
- * moment anything that feeds them changes.
2766
- *
2767
- * Two properties do the work, and neither is optional:
2768
- *
2769
- * **Deliveries are serialized.** The permission decision is asynchronous,
2770
- * so without a queue two changes to one record could resolve out of order
2771
- * and be delivered newest-first — breaking the one ordering guarantee the
2772
- * feed makes. Every emission is appended to a chain instead.
2773
- *
2774
- * **The filter fails closed.** A permission check that throws drops the
2775
- * event and reports the error; it never delivers on the assumption that a
2776
- * failed check would have passed.
2777
- *
2778
- * See docs/spec/events.md § Permission scoping.
2779
- */
2780
- class ScopedSubscription extends Subscription {
2781
- canRead;
2782
- cache = new FeedAuthorityCache();
2783
- /** Tail of the delivery chain — see the class comment. */
2784
- queue = Promise.resolve();
2785
- constructor(canRead, handler, opts) {
2786
- super(handler, opts);
2787
- this.canRead = canRead;
2788
- }
2789
- accept(emission) {
2790
- // Invalidation reads every emission, including the ones this
2791
- // subscriber may not see: a revocation the subscriber cannot read is
2792
- // exactly the one that must still expire its cache.
2793
- this.invalidateFor(emission);
2794
- if (!matchesFilter(emission, this.opts.filter))
2795
- return;
2796
- this.queue = this.queue.then(() => this.filterAndDeliver(emission));
2797
- }
2798
- /**
2799
- * A cached grant set outlives the write that revokes it unless something
2800
- * drops it. Both writes that can change canRead's answer — a `_grant`
2801
- * record, or a `_group` roster — arrive here as ordinary events, which
2802
- * is what makes the cache safe to hold at all. A future authority change
2803
- * that did not emit would silently strand it.
2804
- */
2805
- invalidateFor(emission) {
2806
- this.cache.invalidateFor(baseIdOf(emission.change.typeId));
2807
- }
2808
- async filterAndDeliver(emission) {
2809
- if (this.isClosed)
2810
- return;
2811
- if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted))
2812
- return;
2813
- try {
2814
- if (!(await this.canRead(emission.record, this.cache)))
2815
- return;
2816
- }
2817
- catch (err) {
2818
- // Fail closed: an undecided permission question is not a yes.
2819
- this.reportError(err);
2820
- return;
2821
- }
2822
- // After the permission decision, which needs the whole record: a frame
2823
- // carrying a deleted record's body would be a read channel around the
2824
- // tombstone. See docs/spec/events.md § Soft-deleted records reach the
2825
- // feed as tombstones.
2826
- this.deliver(this.project(emission));
2827
- }
2828
- project(emission) {
2829
- const record = presentDeleted(emission.record);
2830
- return record === emission.record ? emission : { ...emission, record };
2831
- }
2832
- }
2833
- export class ScopedStack {
2834
- stack;
2835
- principalEntityId;
2836
- subjectEntityId;
2837
- idTimestampSkewMs;
2838
- adapter;
2839
- changes;
2840
- constructor(stack, principalEntityId, subjectEntityId, idTimestampSkewMs,
2841
- // Bytes-storage primitive for putAttachment(). Held directly because
2842
- // ScopedStack always composes bytes + its own create() — the record
2843
- // must carry the subject's entityId and the principal behind it,
2844
- // neither of which the adapter-level atomic capability takes.
2845
- adapter,
2846
- // The stack's own stream, filtered by subscribe(). Held here rather
2847
- // than reached through a method on Stack so the unfiltered stream
2848
- // stays inside this module.
2849
- changes) {
2850
- this.stack = stack;
2851
- this.principalEntityId = principalEntityId;
2852
- this.subjectEntityId = subjectEntityId;
2853
- this.idTimestampSkewMs = idTimestampSkewMs;
2854
- this.adapter = adapter;
2855
- this.changes = changes;
2856
- }
2857
- get features() {
2858
- return this.stack.features;
2859
- }
2860
- resolveRecord = (id) => this.stack.get(id);
2861
- /** Whether a delegated app is acting for someone other than itself. */
2862
- get delegated() {
2863
- return this.subjectEntityId !== this.principalEntityId;
2864
- }
2865
- /**
2866
- * Who this request is, for stamping onto whatever it mutates. Attribution
2867
- * follows record-level authorship: the subject is the actor, and the
2868
- * principal is named beside it only when the two differ.
2869
- * See docs/spec/data-model.md § Authorship and attribution.
2870
- */
2871
- get actor() {
2872
- // Both keys are always present, so spreading this last overrides
2873
- // anything a caller passed: a scoped requester names itself by making
2874
- // the request, never by describing itself in the options.
2875
- return {
2876
- updatedBy: this.subjectEntityId ?? undefined,
2877
- updatedVia: this.delegated ? (this.principalEntityId ?? undefined) : undefined,
2878
- };
2879
- }
2880
- /**
2881
- * Whether unconditional owner authority applies — the owner acting as
2882
- * itself. The verbs that rest on it are irreversible or disclose the
2883
- * sharing graph, so delegation never carries one to a subject, whichever
2884
- * side the owner is on. Shared with the predicate a server applies to a
2885
- * session, which decides the same tier one step earlier. See
2886
- * docs/spec/access-control.md § Delegation: principal and subject.
2887
- */
2888
- get ownerActingAlone() {
2889
- return isOwnerActingAlone({ principalId: this.principalEntityId, subjectId: this.subjectEntityId }, this.stack.ownerEntityId);
2890
- }
2891
- checkRead(record) {
2892
- return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'read', this.resolveRecord);
2893
- }
2894
- checkWrite(record) {
2895
- return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'write', this.resolveRecord);
2896
- }
2897
- /**
2898
- * Whether `grantee` holds a _grant covering one of `actions` for the
2899
- * type's family (grants match by baseId, so a version bump never orphans
2900
- * one). -own actions additionally require record.entityId === grantee,
2901
- * unless `matchOwn` is false — on the principal side of a delegated
2902
- * request the suffix is read as the bare verb, since which records are
2903
- * reachable is the subject's business. `allowDefault` decides whether a
2904
- * grant naming nobody counts. Anonymous grantees always return false.
2905
- *
2906
- * Reached only through subjectAllows()/principalAllows(), which fix those
2907
- * two flags per side of the intersection. Call one of those instead.
2908
- * See docs/spec/access-control.md § Type-level grants.
2909
- */
2910
- async hasGrant(typeId, actions, opts) {
2911
- const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
2912
- if (!grantee)
2913
- return false;
2914
- // Absent when the caller has no operation-scoped map to share — one
2915
- // call's worth of memoization, which is still every grant record in
2916
- // this loop naming the same group.
2917
- const groupRoles = opts.groupRoles ?? new Map();
2918
- const familyId = baseIdOf(typeId);
2919
- // grant() refuses to write these, but a _grant record is an ordinary
2920
- // Record: an unscoped Stack, an import, or a server mapping a request
2921
- // body onto Stack can mint one anyway. Refusing at the point of use is
2922
- // what makes the rule hold regardless of how the record got there.
2923
- if (UNGRANTABLE_SYSTEM_TYPES.has(familyId))
2924
- return false;
2925
- let grantRecords;
2926
- if (prefetchedGrants !== undefined) {
2927
- grantRecords = prefetchedGrants;
2928
- }
2929
- else {
2930
- // No content-field prefilter: a stored grant's typeId may be a bare
2931
- // baseId or versioned, so exact matching would wrongly exclude family
2932
- // versions. Cursor-walked to see grants past page one.
2933
- grantRecords = await queryAllPages((q) => this.stack.query(q), {
2934
- filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2935
- });
2936
- }
2937
- for (const r of grantRecords) {
2938
- const c = r.content;
2939
- if (baseIdOf(c.typeId) !== familyId)
2940
- continue;
2941
- const covers = await grantCoversGrantee(c, grantee, {
2942
- allowDefault,
2943
- allowGroup,
2944
- groupRoles,
2945
- resolveRecord: this.resolveRecord,
2946
- });
2947
- if (!covers)
2948
- continue;
2949
- const matches = actions.some((action) => {
2950
- if (!grantConveys(c.actions, action))
2951
- return false;
2952
- if (matchOwn && action.endsWith('-own'))
2953
- return record?.entityId === grantee;
2954
- return true;
2955
- });
2956
- if (matches)
2957
- return true;
2958
- }
2959
- return false;
2960
- }
2961
- /**
2962
- * The principal half of a delegated request's authority: does the app
2963
- * hold any grant permitting these verbs on this type at all. Bounds what
2964
- * the subject's own authority can reach through it, so a powerful app
2965
- * can never lend its reach to a weaker subject — nor the reverse.
2966
- * Vacuously true when there's no delegation, where the principal and
2967
- * subject checks would be the same question asked twice.
2968
- *
2969
- * Default grants don't count here. "Any authenticated entity" is about
2970
- * people who turn up, not software the owner installed — an app reaches
2971
- * only the types named to it, which is the whole of what containment
2972
- * promises.
2973
- *
2974
- * Group-targeted grants don't count here either, one step removed: a
2975
- * roster is editable by any of the group's admins, so authority reaching
2976
- * a principal through one would let someone other than the owner name an
2977
- * app to a type. See docs/spec/access-control.md § Type-level grants.
2978
- */
2979
- principalAllows(typeId, actions, prefetchedGrants) {
2980
- if (!this.delegated)
2981
- return Promise.resolve(true);
2982
- if (this.principalEntityId === this.stack.ownerEntityId)
2983
- return Promise.resolve(true);
2984
- return this.hasGrant(typeId, actions, {
2985
- grantee: this.principalEntityId,
2986
- prefetchedGrants,
2987
- matchOwn: false,
2988
- allowDefault: false,
2989
- allowGroup: false,
2990
- });
2991
- }
2992
- /**
2993
- * The subject half: which records are reachable, answered with `-own`
2994
- * matching and default grants both in force — the ordinary reading of a
2995
- * grant, since the subject is the entity a grant is written about.
2996
- *
2997
- * Paired with principalAllows() so that the two halves of the
2998
- * intersection are the only callers of hasGrant(): its flags differ per
2999
- * side and mean nothing on their own, so no call site sets them by hand.
3000
- */
3001
- subjectAllows(typeId, actions, opts = {}) {
3002
- return this.hasGrant(typeId, actions, {
3003
- grantee: this.subjectEntityId,
3004
- record: opts.record,
3005
- prefetchedGrants: opts.prefetchedGrants,
3006
- groupRoles: opts.groupRoles,
3007
- });
3008
- }
3009
- /**
3010
- * How to refuse a record this request addressed by ID. A requester who
3011
- * can read the record is told it exists and the verb was refused;
3012
- * everyone else is told what a missing ID is told, so no one learns an ID
3013
- * is live who could not have learned it by reading. `message` therefore
3014
- * only ever reaches someone holding the record already.
3015
- * See docs/spec/access-control.md § Errors and information exposure.
3016
- */
3017
- async denialFor(record, message) {
3018
- // Prefetched here rather than threaded down from the gate: a write
3019
- // carried by a record-level permission settles without reading a grant
3020
- // at all, and that path must not pay for this one. Both halves of
3021
- // canRead share the one scan.
3022
- const grants = this.principalEntityId || this.subjectEntityId
3023
- ? await queryAllPages((q) => this.stack.query(q), {
3024
- filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
3025
- })
3026
- : undefined;
3027
- if (await this.canRead(record, grants))
3028
- return new StackPermissionError(message);
3029
- return new StackNotFoundError(`Record not found: "${record.id}"`);
3030
- }
3031
- async canRead(record, prefetchedGrants, groupRoles) {
3032
- const reachable = (await this.checkRead(record)) ||
3033
- (await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
3034
- record,
3035
- prefetchedGrants,
3036
- groupRoles,
3037
- }));
3038
- if (!reachable)
3039
- return false;
3040
- return this.principalAllows(record.typeId, ['read-own', 'read-any'], prefetchedGrants);
3041
- }
3042
- async checkCreateGrant(typeId) {
3043
- if (this.ownerActingAlone)
3044
- return true;
3045
- const reachable = this.subjectEntityId === this.stack.ownerEntityId ||
3046
- (await this.subjectAllows(typeId, ['create']));
3047
- if (!reachable)
3048
- return false;
3049
- return this.principalAllows(typeId, ['create']);
3050
- }
3051
- /**
3052
- * `_group` records are managed, not merely written: only the owner or an
3053
- * `admin` roster holder may mutate them — ordinary write permissions and
3054
- * grants don't apply. Asked of both identities under delegation, like
3055
- * setPermissions(). See docs/spec/identity.md § Group.
3056
- */
3057
- isGroupManager(record) {
3058
- if (!this.managesGroup(this.principalEntityId, record))
3059
- return false;
3060
- return !this.delegated || this.managesGroup(this.subjectEntityId, record);
3061
- }
3062
- /** Whether one identity, on its own, manages `record` — see isGroupManager(). */
3063
- managesGroup(entityId, record) {
3064
- if (!entityId)
3065
- return false;
3066
- if (entityId === this.stack.ownerEntityId)
3067
- return true;
3068
- return groupRoleFromAssociations(record.associations, entityId) === 'admin';
3069
- }
3070
- /**
3071
- * Fetch a record the subject can reach and the principal holds `update` on
3072
- * (via permissions or an update grant), or throw. `mutating: false` marks
3073
- * the history readers, which borrow this gate without changing anything —
3074
- * the one way through it a `_grant` Record stays open to.
3075
- */
3076
- async requireUpdatable(id, opts = {}) {
3077
- const record = await this.stack.get(id);
3078
- if (!record)
3079
- throw new StackNotFoundError(`Record not found: "${id}"`);
3080
- const mutating = opts.mutating ?? true;
3081
- if (mutating)
3082
- await this.requireOwnerForGrantRecord(record);
3083
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3084
- if (!this.isGroupManager(record))
3085
- throw await this.denialFor(record);
3086
- return this.refuseIfDeleted(record, mutating);
3087
- }
3088
- const allowed = ((await this.checkWrite(record)) ||
3089
- (await this.subjectAllows(record.typeId, ['update-own', 'update-any'], { record }))) &&
3090
- (await this.principalAllows(record.typeId, ['update-own', 'update-any']));
3091
- if (!allowed)
3092
- throw await this.denialFor(record);
3093
- return this.refuseIfDeleted(record, mutating);
3094
- }
3095
- /**
3096
- * Refuse a mutation aimed at a soft-deleted Record. Asked only of a
3097
- * mutating caller, so the history readers borrowing this gate still work,
3098
- * and only after the authority decision, so "exists but deleted" is never
3099
- * a probe a stranger can run. See docs/spec/versioning.md § Mutations are
3100
- * refused, not applied to a tombstone.
3101
- */
3102
- refuseIfDeleted(record, mutating) {
3103
- if (mutating && record.deletedAt) {
3104
- throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
3105
- }
3106
- return record;
3107
- }
3108
- /**
3109
- * Fetch a record the subject can reach and the principal holds `delete` on
3110
- * (via permissions or a delete grant), or throw.
3111
- */
3112
- async requireDeletable(id) {
3113
- const record = await this.stack.get(id);
3114
- if (!record)
3115
- throw new StackNotFoundError(`Record not found: "${id}"`);
3116
- await this.requireOwnerForGrantRecord(record);
3117
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3118
- if (!this.isGroupManager(record))
3119
- throw await this.denialFor(record);
3120
- return record;
3121
- }
3122
- const allowed = ((await this.checkWrite(record)) ||
3123
- (await this.subjectAllows(record.typeId, ['delete-own', 'delete-any'], { record }))) &&
3124
- (await this.principalAllows(record.typeId, ['delete-own', 'delete-any']));
3125
- if (!allowed)
3126
- throw await this.denialFor(record);
3127
- return record;
3128
- }
3129
- /**
3130
- * Whether this request may reference `recordId` (as a parentId or
3131
- * relationship target). Missing and unreadable both return false —
3132
- * indistinguishable, so this can't probe for a record's existence.
3133
- */
3134
- async canReadReferent(recordId) {
3135
- const record = await this.stack.get(recordId);
3136
- if (!record)
3137
- return false;
3138
- return this.canRead(record);
3139
- }
3140
- /**
3141
- * Whether this request can read some record referencing `fileId` —
3142
- * shared by canAccessFile() and the non-owner _attachment@1 create()
3143
- * carve-out, which deliberately excludes the uploader clause. Walks
3144
- * every referencing record, short-circuiting on the first readable one.
3145
- * `_attachment@1` records are excluded from matching outright: the
3146
- * carve-out must be satisfied by some *other* record referencing the
3147
- * file, never by the requester's own prior metadata record for it —
3148
- * allowing that would let one successful guess unlock unlimited further
3149
- * metadata records for the same fileId, reintroducing the circularity
3150
- * the carve-out's uploader-clause exclusion closes.
3151
- * See docs/spec/attachments.md § Creating `_attachment@1` records directly.
3152
- */
3153
- async hasReadableReference(fileId) {
3154
- const prefetchedGrants = this.subjectEntityId
3155
- ? await queryAllPages((q) => this.stack.query(q), {
3156
- filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
3157
- })
3158
- : undefined;
3159
- const groupRoles = new Map();
3160
- const match = await findFirstMatch((q) => this.stack.query(q),
3161
- // Reach, not enumeration: a record readable by ID conveys the file it
3162
- // references. See docs/spec/unlisted.md.
3163
- { filter: { attachmentFileId: fileId, includeUnlisted: true } }, (record) => baseIdOf(record.typeId) !== SYSTEM_TYPES.ATTACHMENT &&
3164
- this.canRead(record, prefetchedGrants, groupRoles));
3165
- return match !== undefined;
3166
- }
3167
- /**
3168
- * Whether this request may reference or download `fileId` — the dual of
3169
- * getAttachment()'s access rule. Nonexistent and inaccessible are
3170
- * indistinguishable (both false), so no confirmation oracle for guessed
3171
- * hashes. See docs/spec/access-control.md § Reference-creation gating.
3172
- */
3173
- async canAccessFile(fileId) {
3174
- if (this.ownerActingAlone)
3175
- return true;
3176
- // Reaching a file through a record this request can read is already
3177
- // fully intersected — canRead() applied the principal's mask against
3178
- // that record's own type, which is the type the reference lives on.
3179
- if (await this.hasReadableReference(fileId))
3180
- return true;
3181
- if (!this.subjectEntityId)
3182
- return false;
3183
- // The remaining paths are authorship facts about the subject, so they
3184
- // decide *which* files match — they are not themselves a grant, and the
3185
- // principal still needs one of its own on the attachment type.
3186
- if (!(await this.principalAllows(`${SYSTEM_TYPES.ATTACHMENT}@1`, ['read-own', 'read-any']))) {
3187
- return false;
3188
- }
3189
- if (this.subjectEntityId === this.stack.ownerEntityId)
3190
- return true;
3191
- return filtersContent(this.stack.features)
3192
- ? (await this.stack.query({
3193
- filter: {
3194
- typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
3195
- entityId: this.subjectEntityId,
3196
- content: { fileId },
3197
- },
3198
- limit: 1,
3199
- })).records.length > 0
3200
- : (await queryAllPages((q) => this.stack.query(q), {
3201
- filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, entityId: this.subjectEntityId },
3202
- })).some((r) => r.content.fileId === fileId);
3203
- }
3204
- /** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
3205
- async fileRefFieldNames(typeId) {
3206
- const type = await this.stack.getType(typeId);
3207
- if (!type)
3208
- return [];
3209
- return Object.entries(type.schema)
3210
- .filter(([, def]) => def.kind === 'file-ref')
3211
- .map(([field]) => field);
3212
- }
3213
- /**
3214
- * Gates file-ref content fields on file access, mirroring the
3215
- * attachment-association gate — a file-ref field conveys attachment
3216
- * access exactly like an `attachment` association. Only fields present
3217
- * in `content` are checked (update() is a merge patch; untouched fields
3218
- * carry no new reference).
3219
- */
3220
- async requireFileRefAccess(typeId, content) {
3221
- for (const field of await this.fileRefFieldNames(typeId)) {
3222
- const value = content[field];
3223
- if (typeof value !== 'string')
3224
- continue;
3225
- if (!(await this.canAccessFile(value)))
3226
- throw new StackPermissionError();
3227
- }
3228
- }
3229
- /**
3230
- * Reference-creation gate for one association: `attachment` requires
3231
- * file access, and a `relationship` naming a record in this stack
3232
- * requires read access to it. `tag` is unchecked, and `_group` roster
3233
- * associations are gated by the stricter isGroupManager() instead.
3234
- *
3235
- * The other target arms are ungated because the gate's purpose —
3236
- * refusing a reference that would convey access to, or confirm the
3237
- * existence of, an unreadable record — has nothing to bite on: core
3238
- * never resolves them, so no access flows through one.
3239
- * See docs/spec/access-control.md § Reference-creation gating.
3240
- */
3241
- async requireAssociationAccess(typeId, association) {
3242
- if (association.kind === 'attachment') {
3243
- if (!(await this.canAccessFile(association.fileId)))
3244
- throw new StackPermissionError();
3245
- }
3246
- else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
3247
- const { target } = association;
3248
- // `stackUrl` is tested for a value, not for presence: absent and
3249
- // empty are one target — storage, targetEqual() and the filter all
3250
- // read them as this stack — so a check on presence alone would
3251
- // leave one spelling of a local Record ungated.
3252
- if (target.scope !== 'record' || target.stackUrl)
3253
- return;
3254
- if (!(await this.canReadReferent(target.recordId)))
3255
- throw new StackPermissionError();
3256
- }
3257
- }
3258
- /**
3259
- * Create a record on behalf of the subject: create grant required,
3260
- * anonymous denied, entityId set to the subject, client IDs skew-checked,
3261
- * reference-creating options gated, and non-owner `_attachment@1`
3262
- * creation refused save one carve-out. A scoped create always stamps
3263
- * authorship — an absent entityId means an unscoped `Stack` wrote it.
3264
- * `createdAt`/`updatedAt` are refused to everyone but the owner acting
3265
- * alone — see the guard below and docs/spec/data-model.md § Record IDs.
3266
- * See also docs/spec/access-control.md and docs/spec/attachments.md.
3267
- */
3268
- async create(typeId, content, opts = {}) {
3269
- const principal = this.principalEntityId;
3270
- if (!principal)
3271
- throw new StackPermissionError('Anonymous requesters cannot create records');
3272
- // createdAt/updatedAt let a caller backdate a record's clock fields —
3273
- // and, without `id` also supplied, its sort position too. Refused to
3274
- // everyone but the owner acting alone (undelegated, authenticated as
3275
- // themselves): a grantee is exactly the untrusted actor the `id`
3276
- // skew check below already exists to stop from forging a sort
3277
- // position, and a delegated app acting for the owner inherits none of
3278
- // the owner's extra trust — same reasoning as mayGrantAccess() below.
3279
- // Refused rather than silently dropped, so an app never believes it
3280
- // published something it didn't — asked value-wise, since an
3281
- // `undefined` carries no date and Stack.create() reads it as absent.
3282
- // This is also the enforcement a server built on ScopedStack inherits
3283
- // for `POST /records`: an owner-authenticated request may carry both
3284
- // fields, anyone else's has them ignored.
3285
- if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) && !this.ownerActingAlone) {
3286
- 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.');
3287
- }
3288
- if (!(await this.checkCreateGrant(typeId))) {
3289
- throw new StackPermissionError(`No create grant for type "${typeId}"`);
3290
- }
3291
- // The exemption is the owner's own, so delegation doesn't carry it: an
3292
- // owner principal acting for someone else would otherwise let that
3293
- // subject name any fileId and reach the bytes through the uploader
3294
- // clause, which matches on the subject this create stamps.
3295
- if (!this.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
3296
- const fileId = content.fileId;
3297
- if (typeof fileId !== 'string' || !(await this.hasReadableReference(fileId))) {
3298
- throw new StackPermissionError();
3299
- }
3300
- }
3301
- if (opts.permissions?.length && !this.mayGrantAccess()) {
3302
- throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
3303
- }
3304
- if (opts.unlisted && !this.mayGrantAccess()) {
3305
- throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
3306
- }
3307
- this.requireOwnerForOwnerDid(typeId, content.did);
3308
- await this.requireAppIdMatchesPrincipal(opts.appId);
3309
- if (opts.id !== undefined) {
3310
- validateRecordId(opts.id);
3311
- // Skipped when createdAt is also supplied: only the owner reaches
3312
- // here with that combination (checked above), and Stack.create()
3313
- // below checks the id against createdAt instead of "now" — the
3314
- // check here exists for a live grantee write, and a backdated
3315
- // owner create is deliberately not one. See
3316
- // docs/spec/data-model.md § Record IDs.
3317
- if (opts.createdAt === undefined) {
3318
- validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
3319
- }
3320
- }
3321
- if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
3322
- throw new StackPermissionError();
3323
- }
3324
- for (const assoc of opts.associations ?? []) {
3325
- await this.requireAssociationAccess(typeId, assoc);
3326
- }
3327
- await this.requireFileRefAccess(typeId, content);
3328
- return this.stack.create(typeId, content, {
3329
- ...opts,
3330
- entityId: this.subjectEntityId ?? undefined,
3331
- principalId: this.delegated ? principal : undefined,
3332
- });
3333
- }
3334
- /**
3335
- * Whether this request may decide who else reaches a record — the rule
3336
- * setPermissions() enforces, asked at create time too so the reach it
3337
- * withholds can't be taken one step earlier while authoring. A delegated
3338
- * app is denied it: widening access is the one thing containment most
3339
- * needs to hold. Refused rather than silently ignored, so an app never
3340
- * believes it published something it didn't. Not `ownerActingAlone`:
3341
- * the record is the subject's own, so an owner principal grants it no
3342
- * reach the subject lacks.
3343
- * See docs/spec/access-control.md § Delegation: principal and subject.
3344
- */
3345
- mayGrantAccess() {
3346
- return !this.delegated || this.principalEntityId === this.stack.ownerEntityId;
3347
- }
3348
- /**
3349
- * Whether one identity, on its own, may decide who else reaches `record`
3350
- * — the owner-or-creator rule setPermissions() enforces, asked of one
3351
- * side at a time. See
3352
- * docs/spec/access-control.md § Delegation: principal and subject.
3353
- */
3354
- mayReshare(entityId, record) {
3355
- if (!entityId)
3356
- return false;
3357
- return entityId === this.stack.ownerEntityId || entityId === record.entityId;
3358
- }
3359
- /**
3360
- * A record this request may read, or null. Never throws
3361
- * StackPermissionError: an unreadable record answers exactly as a missing
3362
- * one does, so a caller learns only what it may read.
3363
- * See docs/spec/access-control.md § Errors and information exposure.
3364
- */
3365
- async get(id, opts = {}) {
3366
- const record = await this.stack.get(id, opts);
3367
- if (!record)
3368
- return null;
3369
- if (!(await this.canRead(record)))
3370
- return null;
3371
- return presentDeleted(record);
3372
- }
3373
- /**
3374
- * Runs through this.query(), so canRead() still applies — this answers a
3375
- * question the caller asks on its own behalf. includeUnlisted is passed
3376
- * only when the request is the owner acting alone, per
3377
- * docs/spec/identity.md § DID bindings.
3378
- */
3379
- async getEntityByDid(did) {
3380
- return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.stack.features), this.ownerActingAlone);
3381
- }
3382
- async getOwnerEntity() {
3383
- return this.getEntityByDid(this.stack.ownerEntityId);
3384
- }
3385
- /**
3386
- * Query records, filtered to those this request can read. Pages are
3387
- * filtered then refilled, so a page may slightly overshoot `limit` but
3388
- * never skips a record. Grants are prefetched once, cursor-walked to
3389
- * exhaustion.
3390
- */
3391
- async query(query = {}) {
3392
- assertValidSort(query.sort);
3393
- assertSortCapability(query.sort, this.stack.features);
3394
- assertValidRelatedTo(query.filter?.relatedTo);
3395
- if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
3396
- throw new StackPermissionError('includeUnlisted is owner-only');
3397
- }
3398
- const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
3399
- const records = [];
3400
- const maxFetched = limit * 10;
3401
- let totalFetched = 0;
3402
- const prefetchedGrants = this.principalEntityId
3403
- ? await queryAllPages((q) => this.stack.query(q), {
3404
- filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
3405
- })
3406
- : undefined;
3407
- // Scoped to this query, like prefetchedGrants beside it: every
3408
- // candidate Record shares one roster resolution per group, and nothing
3409
- // is carried into the next operation.
3410
- const groupRoles = new Map();
3411
- let page = { records: [], cursor: query.cursor ?? null };
3412
- do {
3413
- page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
3414
- totalFetched += page.records.length;
3415
- for (const record of page.records) {
3416
- // Projected here as well as in get(), or `includeDeleted` would be
3417
- // a strictly better read channel than the fetch-by-ID it is
3418
- // supposed to match.
3419
- if (await this.canRead(record, prefetchedGrants, groupRoles)) {
3420
- records.push(presentDeleted(record));
3421
- }
3422
- }
3423
- } while (records.length < limit && page.cursor && totalFetched < maxFetched);
3424
- return { records, cursor: page.cursor };
3425
- }
3426
- async update(id, content, opts = {}) {
3427
- const record = await this.requireUpdatable(id);
3428
- // Ahead of the binding fences below, which compare the patch's value
3429
- // for a field against the stored one: `undefined` names a field
3430
- // without claiming a value, so it is refused as a malformed patch
3431
- // rather than read as a change. See Stack.update() for the same check
3432
- // on every other path into a patch.
3433
- const patchErrors = validatePatchValues(content);
3434
- if (patchErrors.length > 0)
3435
- throw new StackValidationError(patchErrors);
3436
- // Value-wise, not presence-wise: a client that reads a card, edits its
3437
- // `name` and sends the whole content object back is not setting the
3438
- // binding it round-trips, and `name` is writable by record-level
3439
- // permission. Same predicate restoreVersion() applies to a snapshot.
3440
- this.requireOwnerForAppIdentity(record.typeId, (field) => field in content && content[field] !== record.content[field]);
3441
- // Likewise value-wise: re-sending the DID a card already holds claims
3442
- // nothing, and immutability refuses changing it regardless.
3443
- if ('did' in content && content.did !== record.content.did) {
3444
- this.requireOwnerForOwnerDid(record.typeId, content.did);
3445
- }
3446
- await this.requireFileRefAccess(record.typeId, content);
3447
- return this.stack.update(id, content, { ...opts, ...this.actor });
3448
- }
3449
- /**
3450
- * Naming the software behind a key is the trust decision the `_app`
3451
- * registry exists to record, so both halves of that binding — `did` and
3452
- * `appId` — belong to the owner alone. Same reasoning that makes `_app`
3453
- * ungrantable, applied to the fields a lookup reads. Registering a card
3454
- * is already owner-only; without this, record-level `write` shared on a
3455
- * card would be a second way in: a card carrying no DID yet could be
3456
- * pointed at a write-holder's own key while keeping the name the owner
3457
- * gave it, or relabelled to claim another app's `appId`. `name` and
3458
- * `version` stay writable — they are display, not lookup.
3459
- *
3460
- * Owner *acting alone*, in both directions: a delegated app never holds
3461
- * it, and an owner principal doesn't lend it to a subject holding
3462
- * record-level `write` on a card — which would reopen the same route
3463
- * from the other side.
3464
- *
3465
- * `_entity` deliberately does not get this rule: naming people is what a
3466
- * contacts app does, so its cards stay writable by grant. Uniqueness and
3467
- * immutability still bind them. See docs/spec/identity.md § DID bindings.
3468
- */
3469
- requireOwnerForAppIdentity(typeId, touches) {
3470
- if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
3471
- return;
3472
- if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
3473
- return;
3474
- if (!this.ownerActingAlone) {
3475
- throw new StackPermissionError('Only the stack owner may set an _app record’s did or appId');
3476
- }
3477
- }
3478
- /**
3479
- * A self-reported `appId` must agree with the `_app` card naming the
3480
- * principal's DID, where the owner registered one. `principalId` is
3481
- * verified, so letting the pair disagree would leave a verified principal
3482
- * claiming a name the owner gave different software — the cross-check the
3483
- * registry exists for, refused at the write instead of left to each reader.
3484
- * A principal with no card keeps `appId` as the bare self-report it is for
3485
- * every undelegated writer.
3486
- * See docs/spec/identity.md § Attribution and what can be trusted.
3487
- */
3488
- async requireAppIdMatchesPrincipal(appId) {
3489
- if (appId === undefined || !this.delegated)
3490
- return;
3491
- const card = await findFirstMatch((q) => this.stack.query(q), {
3492
- filter: {
3493
- baseId: SYSTEM_TYPES.APP,
3494
- includeDeleted: true,
3495
- includeUnlisted: true,
3496
- ...(filtersContent(this.stack.features) && {
3497
- content: { did: this.principalEntityId },
3498
- }),
3499
- },
3500
- }, (r) => r.content.did === this.principalEntityId);
3501
- if (card && card.content.appId !== appId) {
3502
- throw new StackPermissionError(`appId "${appId}" is not the appId registered for this principal`);
3503
- }
3504
- }
3505
- /**
3506
- * The owner's own DID is the one `_entity` binding a grantee may not claim.
3507
- * `ownerProfile` adopts whichever card holds it, so a card minted by
3508
- * someone else becomes the stack's own profile, and uniqueness then makes
3509
- * that permanent. Every other DID stays open to a contacts app, which is
3510
- * the reach `_entity` is grantable for.
3511
- * See docs/spec/identity.md § DID bindings.
3512
- */
3513
- requireOwnerForOwnerDid(typeId, did) {
3514
- if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
3515
- return;
3516
- if (did !== this.stack.ownerEntityId)
3517
- return;
3518
- if (!this.ownerActingAlone) {
3519
- throw new StackPermissionError('Only the stack owner may claim the owner’s own did');
3520
- }
3521
- }
3522
- /**
3523
- * A `_grant` Record *is* authority, so rewriting one is the escalation
3524
- * UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
3525
- * existing grant rather than minting a fresh one. `grant()` and `revoke()`
3526
- * live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
3527
- * only: reading a grant Record and its history stays on the ordinary gate.
3528
- * See docs/spec/access-control.md § Type-level grants.
3529
- */
3530
- async requireOwnerForGrantRecord(record) {
3531
- if (baseIdOf(record.typeId) !== SYSTEM_TYPES.GRANT)
3532
- return;
3533
- if (this.ownerActingAlone)
3534
- return;
3535
- throw await this.denialFor(record, 'Only the stack owner may write a _grant record');
3536
- }
3537
- async associate(id, association, opts = {}) {
3538
- const record = await this.requireUpdatable(id);
3539
- await this.requireAssociationAccess(record.typeId, association);
3540
- return this.stack.associate(id, association, { ...opts, ...this.actor });
3541
- }
3542
- async dissociate(id, association, opts = {}) {
3543
- await this.requireUpdatable(id);
3544
- return this.stack.dissociate(id, association, { ...opts, ...this.actor });
3545
- }
3546
- async setPermissions(id, permissions, opts = {}) {
3547
- const record = await this.stack.get(id);
3548
- if (!record)
3549
- throw new StackNotFoundError(`Record not found: "${id}"`);
3550
- await this.requireOwnerForGrantRecord(record);
3551
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3552
- // Group management, not authorship: a creator later demoted from the
3553
- // admin roster shouldn't retain a side door to reassign who can read
3554
- // or write the group record. Same gate as update/associate/delete.
3555
- if (!this.isGroupManager(record))
3556
- throw await this.denialFor(record);
3557
- }
3558
- else {
3559
- // Intersected like every other authority here: the principal must
3560
- // hold the verb, and the subject must be able to reach this record —
3561
- // without which an owner principal would carry its subject to records
3562
- // the subject cannot touch. create() withholds the same reach via
3563
- // mayGrantAccess().
3564
- if (!this.mayReshare(this.principalEntityId, record))
3565
- throw await this.denialFor(record);
3566
- if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3567
- throw await this.denialFor(record);
3568
- }
3569
- }
3570
- // Reached through its own gate rather than requireUpdatable(), so the
3571
- // soft-delete refusal has to be asked here too — after the authority
3572
- // decision above, for the reason refuseIfDeleted() gives.
3573
- this.refuseIfDeleted(record, true);
3574
- return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
3575
- }
3576
- /**
3577
- * Withhold a record from enumeration, or restore it — gated exactly like
3578
- * setPermissions(), since both decide who or what can discover the
3579
- * record rather than merely read it once found. See
3580
- * docs/spec/unlisted.md.
3581
- */
3582
- async setUnlisted(id, unlisted, opts = {}) {
3583
- const record = await this.stack.get(id);
3584
- if (!record)
3585
- throw new StackNotFoundError(`Record not found: "${id}"`);
3586
- await this.requireOwnerForGrantRecord(record);
3587
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3588
- if (!this.isGroupManager(record))
3589
- throw await this.denialFor(record);
3590
- }
3591
- else {
3592
- if (!this.mayReshare(this.principalEntityId, record))
3593
- throw await this.denialFor(record);
3594
- if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3595
- throw await this.denialFor(record);
3596
- }
3597
- }
3598
- // See setPermissions() above — same gate, same reason.
3599
- this.refuseIfDeleted(record, true);
3600
- return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
3601
- }
3602
- /**
3603
- * Move a record into another container, or to the root with `null`.
3604
- * Gated as an ordinary write on the record, plus read access to the
3605
- * destination — the same reference gate `create()` applies to a
3606
- * `parentId`, asked again here so a move cannot reach a container an
3607
- * authoring call could not have named. Not reshare-gated like
3608
- * setUnlisted(): containment decides which listings enumerate a record,
3609
- * never who may read it, so a move discloses it to nobody who could not
3610
- * already read it. The origin is ungated — knowing it requires reading
3611
- * the record, which this caller has already had to do. See
3612
- * docs/spec/access-control.md § Reference-creation gating.
3613
- */
3614
- async setParent(id, parentId, opts = {}) {
3615
- await this.requireUpdatable(id);
3616
- if (parentId !== null && !(await this.canReadReferent(parentId))) {
3617
- throw new StackPermissionError();
3618
- }
3619
- return this.stack.setParent(id, parentId, { ...opts, ...this.actor });
3620
- }
3621
- /**
3622
- * Hard delete is owner-only: it is irreversible and destroys version
3623
- * history, so neither the write bit nor delete-own/delete-any grants
3624
- * reach it, and delegation doesn't carry it either. Everyone else is
3625
- * limited to soft delete.
3626
- */
3627
- async delete(id, opts = {}) {
3628
- await this.requireDeletable(id);
3629
- if (opts.hard && !this.ownerActingAlone) {
3630
- throw new StackPermissionError('Hard delete is owner-only');
3631
- }
3632
- return this.stack.delete(id, { ...opts, ...this.actor });
3633
- }
3634
- /**
3635
- * Reverse a soft delete. Gated the same as delete() — undelete is the
3636
- * inverse of soft delete, so granting one direction without the other
3637
- * would be backwards. Idempotent, per Stack.undelete().
3638
- */
3639
- async undelete(id, opts = {}) {
3640
- await this.requireDeletable(id);
3641
- return this.stack.undelete(id, { ...opts, ...this.actor });
3642
- }
3643
- /**
3644
- * History is the mutation/recovery surface, not a read surface — gated
3645
- * like update(), with snapshot `permissions` stripped for everyone but
3646
- * the owner acting alone — a snapshot's permissions are the stack's
3647
- * sharing graph, which delegation is not a route to. Reading history
3648
- * changes nothing, so it is the one path the `_grant` write fence leaves
3649
- * alone: seeing how a Record you can already read got that way is not
3650
- * the escalation that fence exists to stop, and losing it would leave a
3651
- * write-holder unable to audit the Record they hold.
3652
- * See docs/spec/versioning.md § History access.
3653
- */
3654
- async getVersions(id) {
3655
- await this.requireUpdatable(id, { mutating: false });
3656
- const versions = await this.stack.getVersions(id);
3657
- return this.ownerActingAlone ? versions : versions.map(stripVersionPermissions);
3658
- }
3659
- /** See getVersions() — same mutate-surface gate, same permissions stripping. */
3660
- async getVersion(id, version) {
3661
- await this.requireUpdatable(id, { mutating: false });
3662
- const target = await this.stack.getVersion(id, version);
3663
- if (!target)
3664
- return null;
3665
- return this.ownerActingAlone ? target : stripVersionPermissions(target);
3666
- }
3667
- /**
3668
- * Re-runs the reference-creation checks against the snapshot, so a
3669
- * restore can't re-convey access to a file or record the subject can no
3670
- * longer reach today — the snapshot's `parentId` among them, gated
3671
- * exactly as setParent() gates a destination named directly. Only the
3672
- * owner acting alone is exempt: under delegation the checks resolve
3673
- * against the subject, which is whose reach the restore would widen.
3674
- * See docs/spec/versioning.md § Restore semantics.
3675
- */
3676
- async restoreVersion(id, version, opts = {}) {
3677
- const record = await this.requireUpdatable(id);
3678
- if (!this.ownerActingAlone) {
3679
- const target = await this.stack.getVersion(id, version);
3680
- if (target) {
3681
- // A rollback that would move a card's binding is the same trust
3682
- // decision update() reserves to the owner, reached by another route.
3683
- this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
3684
- record.content[field]);
3685
- // Only a restore that moves the record *into* a container creates a
3686
- // reference. A snapshot at the root names nothing, and a parentId
3687
- // the restore would not change is not re-gated: the record is
3688
- // already there, so a content rollback is not refused over a move
3689
- // it isn't making.
3690
- if (target.parentId !== undefined &&
3691
- target.parentId !== record.parentId &&
3692
- !(await this.canReadReferent(target.parentId))) {
3693
- throw new StackPermissionError();
3694
- }
3695
- await this.requireFileRefAccess(target.typeId, target.content);
3696
- for (const association of target.associations ?? []) {
3697
- await this.requireAssociationAccess(target.typeId, association);
3698
- }
3699
- }
3700
- }
3701
- return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
3702
- }
3703
- /**
3704
- * Commit a per-record migration — **the owner acting alone, only**.
3705
- *
3706
- * Migration is owner-driven by design: `migrateAll()`, the bulk path,
3707
- * lives on `Stack` and is deliberately absent from `StackClient`, the
3708
- * same way `grant()`/`revoke()` are. This is its per-record counterpart
3709
- * and carries the same restriction, rather than inventing a grant model
3710
- * that the bulk path deliberately doesn't have.
3711
- *
3712
- * The restriction is what makes the verb safe to expose at all. Migrate
3713
- * replaces `content` and `typeId` wholesale, so a grant-based version
3714
- * would have to re-derive every gate `create()` applies at the
3715
- * destination *and* every gate `update()` applies over the existing
3716
- * content, and would reopen each one it missed. The sharpest is the
3717
- * non-owner `_attachment@1` refusal create() carries: without it, a
3718
- * requester holding a create grant on `_attachment@1` and write access
3719
- * to any record they authored could migrate that record into the family
3720
- * naming any `fileId`, then read the bytes through canAccessFile()'s
3721
- * uploader clause — the exact escalation that carve-out exists to refuse
3722
- * (see docs/spec/attachments.md § Creating `_attachment@1` records
3723
- * directly). Ordinary write access to a record is not consent to move it
3724
- * between families.
3725
- *
3726
- * A server implementing `POST /records/:id/migrate` therefore serves it
3727
- * to the stack owner and answers 403 otherwise. See
3728
- * docs/spec/data-model.md § Type migrations.
3729
- */
3730
- async commitMigration(id, toTypeId, content, opts = {}) {
3731
- if (!this.ownerActingAlone) {
3732
- throw new StackPermissionError('Only the stack owner may commit a migration');
3733
- }
3734
- return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
3735
- }
3736
- /**
3737
- * Store bytes and create an _attachment@1 metadata record (create grant
3738
- * on `_attachment@1` required; anonymous denied), returning that record.
3739
- * Authorship and principal are stamped exactly as create() does.
3740
- */
3741
- async putAttachment(data, mimeType, filename, appId) {
3742
- // The one ScopedStack path that reaches the adapter without going
3743
- // through Stack first — without this, a closed stack would still write
3744
- // bytes before the delegated create() refused.
3745
- this.stack.assertOpen();
3746
- const principal = this.principalEntityId;
3747
- if (!principal) {
3748
- throw new StackPermissionError('Anonymous requesters cannot upload attachments');
3749
- }
3750
- if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) {
3751
- throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`);
3752
- }
3753
- await this.requireAppIdMatchesPrincipal(appId);
3754
- assertAttachmentSize(data.byteLength, this.features.limits.attachmentBytes);
3755
- const fileId = await this.adapter.putAttachment(data);
3756
- return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
3757
- fileId,
3758
- mimeType,
3759
- size: data.byteLength,
3760
- ...(filename && { filename }),
3761
- }, {
3762
- entityId: this.subjectEntityId ?? undefined,
3763
- principalId: this.delegated ? principal : undefined,
3764
- appId,
3765
- });
3766
- }
3767
- /**
3768
- * Download attachment bytes. Accessible to the owner, a reader of any
3769
- * referencing record, or the uploader pre-association — the same
3770
- * predicate as the reference-creation gate (canAccessFile).
3771
- */
3772
- async getAttachment(fileId) {
3773
- if (!(await this.canAccessFile(fileId)))
3774
- throw new StackPermissionError();
3775
- return this.stack.getAttachment(fileId);
3776
- }
3777
- /**
3778
- * Delete an attachment. Only the stack owner may delete attachments.
3779
- * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
3780
- */
3781
- async deleteAttachment(fileId) {
3782
- if (!this.ownerActingAlone) {
3783
- throw new StackPermissionError('Only the stack owner can delete attachments');
3784
- }
3785
- return this.stack.deleteAttachment(fileId, this.actor);
3786
- }
3787
- /**
3788
- * Sweep for unreferenced attachment bytes and delete them. Only the stack
3789
- * owner may run this. Delegates to Stack.collectAttachmentGarbage().
3790
- */
3791
- async collectAttachmentGarbage(opts) {
3792
- if (!this.ownerActingAlone) {
3793
- throw new StackPermissionError('Only the stack owner can collect attachment garbage');
3794
- }
3795
- return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
3796
- }
3797
- /**
3798
- * Observe the changes this request may read. The predicate is canRead
3799
- * applied per event — the same one get() and query() answer with, so a
3800
- * feed can't disagree with them about what this session sees.
3801
- *
3802
- * A record the subscriber cannot read produces no event at all, rather
3803
- * than an empty or redacted one: the existence of a change is itself a
3804
- * disclosure, the same reasoning that keeps a count of the whole match
3805
- * off a query result. See docs/spec/events.md § Permission scoping.
3806
- */
3807
- async subscribe(handler, opts = {}) {
3808
- this.stack.assertOpen();
3809
- if (this.stack.relaysChanges) {
3810
- throw new StackRelayScopeError('This stack relays changes from elsewhere, and a scoped view cannot narrow that feed: ' +
3811
- 'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
3812
- 'no record to re-check. Subscribe with a session-scoped stack instead.');
3813
- }
3814
- // A relaying stack was already refused above, so relaysChanges is
3815
- // always false here — `since` never has a cursor to mean anything by.
3816
- assertSinceUsable(opts.since, false);
3817
- if (opts.includeUnlisted && !this.ownerActingAlone) {
3818
- throw new StackPermissionError('includeUnlisted is owner-only');
3819
- }
3820
- return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
3821
- }
3822
- /**
3823
- * canRead for one event, through the subscription's own cache. Grants
3824
- * are prefetched once and refilled after an invalidation rather than
3825
- * re-queried per event, which would be a `_grant` scan for every change
3826
- * the stack makes.
3827
- */
3828
- async canReadCached(record, cache) {
3829
- const grants = this.principalEntityId
3830
- ? await cache.grants(() => queryAllPages((q) => this.stack.query(q), {
3831
- filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
3832
- }))
3833
- : undefined;
3834
- return this.canRead(record, grants, cache.roles);
3835
- }
3836
- }
3837
1786
  //# sourceMappingURL=stack.js.map