@haverstack/core 0.8.0 → 0.10.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 (65) hide show
  1. package/README.md +30 -14
  2. package/dist/access.d.ts +20 -7
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +37 -16
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter-entry.d.ts +12 -0
  7. package/dist/adapter-entry.d.ts.map +1 -0
  8. package/dist/adapter-entry.js +11 -0
  9. package/dist/adapter-entry.js.map +1 -0
  10. package/dist/attachment-download.d.ts +75 -0
  11. package/dist/attachment-download.d.ts.map +1 -0
  12. package/dist/attachment-download.js +128 -0
  13. package/dist/attachment-download.js.map +1 -0
  14. package/dist/auth.d.ts +88 -0
  15. package/dist/auth.d.ts.map +1 -0
  16. package/dist/auth.js +145 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/combine.d.ts.map +1 -1
  19. package/dist/combine.js +20 -3
  20. package/dist/combine.js.map +1 -1
  21. package/dist/did-entry.d.ts +12 -0
  22. package/dist/did-entry.d.ts.map +1 -0
  23. package/dist/did-entry.js +16 -0
  24. package/dist/did-entry.js.map +1 -0
  25. package/dist/did.d.ts +51 -0
  26. package/dist/did.d.ts.map +1 -0
  27. package/dist/did.js +153 -0
  28. package/dist/did.js.map +1 -0
  29. package/dist/id.d.ts +18 -6
  30. package/dist/id.d.ts.map +1 -1
  31. package/dist/id.js +39 -10
  32. package/dist/id.js.map +1 -1
  33. package/dist/index.d.ts +15 -10
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +14 -9
  36. package/dist/index.js.map +1 -1
  37. package/dist/merge.d.ts +11 -0
  38. package/dist/merge.d.ts.map +1 -0
  39. package/dist/merge.js +38 -0
  40. package/dist/merge.js.map +1 -0
  41. package/dist/schema.d.ts +31 -9
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +134 -11
  44. package/dist/schema.js.map +1 -1
  45. package/dist/stack.d.ts +850 -152
  46. package/dist/stack.d.ts.map +1 -1
  47. package/dist/stack.js +2017 -365
  48. package/dist/stack.js.map +1 -1
  49. package/dist/testing.d.ts +85 -23
  50. package/dist/testing.d.ts.map +1 -1
  51. package/dist/testing.js +261 -19
  52. package/dist/testing.js.map +1 -1
  53. package/dist/types.d.ts +281 -31
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +14 -0
  56. package/dist/types.js.map +1 -1
  57. package/dist/validate.d.ts +26 -0
  58. package/dist/validate.d.ts.map +1 -1
  59. package/dist/validate.js +51 -1
  60. package/dist/validate.js.map +1 -1
  61. package/dist/wire-entry.d.ts +17 -0
  62. package/dist/wire-entry.d.ts.map +1 -0
  63. package/dist/wire-entry.js +24 -0
  64. package/dist/wire-entry.js.map +1 -0
  65. package/package.json +13 -1
package/dist/stack.js CHANGED
@@ -7,73 +7,442 @@
7
7
  * - ID generation
8
8
  * - Type definition and schema hashing
9
9
  * - Content validation on write
10
- * - Migration registry and auto-migration on read
10
+ * - Migration registry and explicit, owner-driven migrateAll()
11
11
  * - Version snapshotting on update
12
12
  * - Soft and hard delete
13
13
  *
14
14
  * Apps should never talk to a StackAdapter directly.
15
15
  */
16
- import { generateId } from './id.js';
17
- import { hashSchema, isCompatible, parseTypeId } from './schema.js';
18
- import { validateContent } from './validate.js';
19
- import { checkAccess } from './access.js';
20
- import { SYSTEM_TYPES } from './types.js';
21
- export class StackValidationError extends Error {
16
+ import { generateId, isValidIdFormat, idTimestamp } from './id.js';
17
+ import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
18
+ import { validateContent, validateReservedKeys } from './validate.js';
19
+ import { applyMergePatch } from './merge.js';
20
+ import { checkAccess, groupRoleFromAssociations } from './access.js';
21
+ import { firstRecordedAttachment } from './attachment-download.js';
22
+ import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
23
+ // -------------------------------------------------------
24
+ // Supporting types
25
+ // -------------------------------------------------------
26
+ /** Sentinel: filter.baseId resolved to zero matching types. */
27
+ const EMPTY_FAMILY = Symbol('empty-family');
28
+ /**
29
+ * Valid GrantAction values, for runtime validation in Stack.grant().
30
+ * Built from GRANT_ACTIONS (types.ts), the source of truth GrantAction is
31
+ * itself derived from — so this can't drift from the type.
32
+ */
33
+ const GRANT_ACTION_SET = new Set(GRANT_ACTIONS);
34
+ /**
35
+ * System type families grant() refuses to target: a grant on any of them
36
+ * would let the grantee mint their own grants, touch stack config, or
37
+ * register an app card claiming a DID that isn't theirs — the last of
38
+ * which is what verified app attribution rests on. See
39
+ * docs/spec/access-control.md § Type-level grants.
40
+ */
41
+ const UNGRANTABLE_SYSTEM_TYPES = new Set([
42
+ SYSTEM_TYPES.GRANT,
43
+ SYSTEM_TYPES.CONFIG,
44
+ SYSTEM_TYPES.APP,
45
+ ]);
46
+ /**
47
+ * Content fields that are lookup keys rather than display values: a card
48
+ * claims one, and something later resolves through it. Every one of them is
49
+ * immutable once set. See docs/spec/identity.md § DID bindings.
50
+ */
51
+ const BINDING_FIELDS = new Map([
52
+ [SYSTEM_TYPES.APP, ['did', 'appId']],
53
+ [SYSTEM_TYPES.ENTITY, ['did']],
54
+ ]);
55
+ /**
56
+ * The subset that is additionally unique per stack: the fields something
57
+ * resolves *by*. A Record's `principalId` finds its card by `_app.did` and
58
+ * its `entityId` by `_entity.did`, so a second card claiming either leaves
59
+ * that lookup without a single answer — and ambiguity is all an
60
+ * impersonating card needs.
61
+ *
62
+ * `_app.appId` is deliberately absent. Nothing resolves a card by it — the
63
+ * cross-check reaches the card by `did` and only compares `appId` — so
64
+ * uniqueness would buy no disambiguation, while forbidding the second card
65
+ * key rotation is supposed to produce: `appId` is required, so a
66
+ * replacement card for the same software necessarily repeats it. Moving one
67
+ * card onto another's `appId` is what immutability already refuses.
68
+ * See docs/spec/identity.md § DID bindings.
69
+ */
70
+ const UNIQUE_BINDING_FIELDS = new Map([
71
+ [SYSTEM_TYPES.APP, ['did']],
72
+ [SYSTEM_TYPES.ENTITY, ['did']],
73
+ ]);
74
+ const bindingFieldsOf = (family) => BINDING_FIELDS.get(family) ?? [];
75
+ const uniqueBindingFieldsOf = (family) => UNIQUE_BINDING_FIELDS.get(family) ?? [];
76
+ /**
77
+ * Root of the Stack error taxonomy. A single `instanceof StackError` answers
78
+ * "is this a Stack-domain error or a bug?" — the question a server's error
79
+ * middleware asks before serializing a wire body, and one a nine-arm
80
+ * instanceof ladder answers only by exhaustion. Every subclass carries its
81
+ * discriminator as an instance `code`, so serialization is a lookup rather
82
+ * than a chain of class tests.
83
+ *
84
+ * Membership implies a wire mapping: every code has an entry in
85
+ * WIRE_ERROR_STATUS. Errors with no wire representation (IdGenerationError,
86
+ * InvalidDidError) deliberately stay outside this hierarchy.
87
+ *
88
+ * Subclassing adds no hierarchy beyond this root — notably
89
+ * StackVersionConflictError is a sibling of StackConflictError, not a
90
+ * subtype. See docs/spec/wire-format.md § Error responses.
91
+ */
92
+ export class StackError extends Error {
93
+ }
94
+ export class StackValidationError extends StackError {
22
95
  errors;
96
+ static code = 'validation';
97
+ code = StackValidationError.code;
23
98
  constructor(errors) {
24
99
  super(`Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'));
25
100
  this.errors = errors;
26
101
  this.name = 'StackValidationError';
27
102
  }
28
103
  }
29
- export class StackMigrationError extends Error {
104
+ export class StackMigrationError extends StackError {
105
+ static code = 'migration';
106
+ code = StackMigrationError.code;
30
107
  constructor(message) {
31
108
  super(message);
32
109
  this.name = 'StackMigrationError';
33
110
  }
34
111
  }
35
112
  /** Thrown by ScopedStack when a requester lacks permission for the operation. */
36
- export class StackPermissionError extends Error {
113
+ export class StackPermissionError extends StackError {
114
+ static code = 'permission';
115
+ code = StackPermissionError.code;
37
116
  constructor(message = 'Permission denied') {
38
117
  super(message);
39
118
  this.name = 'StackPermissionError';
40
119
  }
41
120
  }
42
121
  /** Thrown when a record (or specific version) does not exist. */
43
- export class StackNotFoundError extends Error {
122
+ export class StackNotFoundError extends StackError {
123
+ static code = 'not_found';
124
+ code = StackNotFoundError.code;
44
125
  constructor(message) {
45
126
  super(message);
46
127
  this.name = 'StackNotFoundError';
47
128
  }
48
129
  }
49
130
  /** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */
50
- export class StackConflictError extends Error {
131
+ export class StackConflictError extends StackError {
132
+ static code = 'conflict';
133
+ code = StackConflictError.code;
51
134
  constructor(message) {
52
135
  super(message);
53
136
  this.name = 'StackConflictError';
54
137
  }
55
138
  }
139
+ /**
140
+ * Thrown when an `ifVersion` precondition doesn't match a record's current
141
+ * version. Deliberately not a StackConflictError subtype — the two have
142
+ * different recovery stories and HTTP statuses (409 vs. 412). See
143
+ * docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
144
+ */
145
+ export class StackVersionConflictError extends StackError {
146
+ recordId;
147
+ expectedVersion;
148
+ actualVersion;
149
+ static code = 'version_conflict';
150
+ code = StackVersionConflictError.code;
151
+ constructor(message, recordId, expectedVersion, actualVersion) {
152
+ super(message);
153
+ this.recordId = recordId;
154
+ this.expectedVersion = expectedVersion;
155
+ this.actualVersion = actualVersion;
156
+ this.name = 'StackVersionConflictError';
157
+ }
158
+ }
159
+ /**
160
+ * Thrown when a request is structurally malformed — not a content-validation
161
+ * failure, but input the adapter/server can't even interpret (e.g. an
162
+ * undecodable pagination cursor). Distinct from StackValidationError, which
163
+ * means the request was well-formed but content failed schema validation.
164
+ */
165
+ export class StackQueryError extends StackError {
166
+ static code = 'bad_request';
167
+ code = StackQueryError.code;
168
+ constructor(message) {
169
+ super(message);
170
+ this.name = 'StackQueryError';
171
+ }
172
+ }
173
+ /**
174
+ * Fail loud rather than silently widen: a filter the adapter can't honor
175
+ * would otherwise be dropped, returning an unfiltered superset presented
176
+ * as the filtered result. Shared by Stack.query() and
177
+ * APIAdapter.queryRecords(). See docs/spec/data-model.md
178
+ * § Capability-gated filters.
179
+ */
180
+ export function assertQueryCapabilities(filter, capabilities) {
181
+ if (filter?.search && !capabilities.fullTextSearch) {
182
+ throw new StackQueryError('Query uses filter.search, but this adapter does not declare the fullTextSearch capability.');
183
+ }
184
+ if (filter?.content && !capabilities.contentFieldQuery) {
185
+ throw new StackQueryError('Query uses filter.content, but this adapter does not declare the contentFieldQuery capability.');
186
+ }
187
+ }
188
+ /**
189
+ * Thrown when an attachment upload exceeds the adapter's declared
190
+ * `maxAttachmentBytes` ceiling — checked client-side before any bytes are
191
+ * sent; a server still enforces 413 authoritatively regardless. See
192
+ * docs/spec/wire-format.md § Attachments.
193
+ */
194
+ export class StackPayloadTooLargeError extends StackError {
195
+ static code = 'payload_too_large';
196
+ code = StackPayloadTooLargeError.code;
197
+ constructor(message) {
198
+ super(message);
199
+ this.name = 'StackPayloadTooLargeError';
200
+ }
201
+ }
202
+ /**
203
+ * Thrown when a server abandons an operation for taking too long — in
204
+ * practice a full-text search, the one query whose cost the sanitizers
205
+ * bound the *grammar* of but not the execution of (see
206
+ * docs/spec/data-model.md § Capability-gated filters).
207
+ *
208
+ * Never produced in-process: both SQLite engines run synchronously, so
209
+ * there is nothing to interrupt from inside the call. It exists so a
210
+ * server that bounds query time has a class to serialize, and so the app
211
+ * catching it can tell "too expensive, narrow it and retry" from
212
+ * StackQueryError's "malformed, don't bother retrying" — the distinction
213
+ * that would be lost if a timeout reused `bad_request`.
214
+ */
215
+ export class StackTimeoutError extends StackError {
216
+ static code = 'timeout';
217
+ code = StackTimeoutError.code;
218
+ constructor(message) {
219
+ super(message);
220
+ this.name = 'StackTimeoutError';
221
+ }
222
+ }
223
+ /** Shared by Stack.putAttachment() and ScopedStack.putAttachment(). */
224
+ function assertAttachmentSize(byteLength, maxAttachmentBytes) {
225
+ if (maxAttachmentBytes !== null && byteLength > maxAttachmentBytes) {
226
+ throw new StackPayloadTooLargeError(`Attachment (${byteLength} bytes) exceeds the ${maxAttachmentBytes}-byte limit.`);
227
+ }
228
+ }
229
+ /**
230
+ * The content half of the same pre-check, on Stack.create() and
231
+ * Stack.update(). Local adapters declare `maxContentBytes: null` and skip
232
+ * the serialization entirely; only a server declares a ceiling, and its
233
+ * own request-size limit stays authoritative — this just spares an app the
234
+ * round trip and gives it a typed failure instead of a 413 it has to
235
+ * interpret. See docs/spec/wire-format.md § Request size limits.
236
+ */
237
+ function assertContentSize(content, maxContentBytes, what) {
238
+ if (maxContentBytes === null)
239
+ return;
240
+ const byteLength = new TextEncoder().encode(JSON.stringify(content)).length;
241
+ if (byteLength > maxContentBytes) {
242
+ throw new StackPayloadTooLargeError(`${what} (${byteLength} bytes) exceeds the ${maxContentBytes}-byte limit.`);
243
+ }
244
+ }
245
+ /**
246
+ * Thrown by defineType() when redefining an existing typeId with a schema
247
+ * change beyond additive evolution. The remedy is a new version, never an
248
+ * in-place redefinition. See docs/spec/data-model.md § Schema drift
249
+ * detection.
250
+ */
251
+ export class StackSchemaDriftError extends StackError {
252
+ typeId;
253
+ violations;
254
+ static code = 'schema_drift';
255
+ code = StackSchemaDriftError.code;
256
+ constructor(typeId, violations) {
257
+ super(`Schema drift detected for type "${typeId}": the stored schema and the new definition ` +
258
+ `differ beyond additive evolution (new optional fields only). Bump the version instead ` +
259
+ `of redefining "${typeId}" in place — e.g. defineType(\`${baseIdOf(typeId)}@${(parseTypeId(typeId)?.version ?? 0) + 1}\`, ...) plus registerMigration().\n` +
260
+ violations.map((v) => ` ${v.path || '(root)'}: ${v.message}`).join('\n'));
261
+ this.typeId = typeId;
262
+ this.violations = violations;
263
+ this.name = 'StackSchemaDriftError';
264
+ }
265
+ }
266
+ /**
267
+ * Thrown when a Stack or ScopedStack is used after close(). Deliberately
268
+ * outside the StackError taxonomy, alongside IdGenerationError and
269
+ * InvalidDidError: a caller holding a closed client is a local programming
270
+ * error with no wire representation — no server ever responds with it.
271
+ * See docs/spec/adapters.md § Lifecycle.
272
+ */
273
+ export class StackClosedError extends Error {
274
+ constructor(message = 'This Stack has been closed.') {
275
+ super(message);
276
+ this.name = 'StackClosedError';
277
+ }
278
+ }
279
+ // -------------------------------------------------------
280
+ // Record ID validation
281
+ // -------------------------------------------------------
282
+ const RESERVED_ID_PREFIX = '_';
283
+ const DEFAULT_ID_TIMESTAMP_SKEW_MS = 24 * 60 * 60 * 1000;
284
+ /**
285
+ * Default grace period for Stack.collectAttachmentGarbage(), covering the
286
+ * upload-then-associate window. See docs/spec/attachments.md § Garbage
287
+ * collection.
288
+ */
289
+ const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
290
+ /**
291
+ * Format and reserved-prefix checks — full-trust context (Stack.create()).
292
+ * Checked before the format check: the Crockford charset already excludes
293
+ * "_", so a reserved-looking id (e.g. "_config") would otherwise just fail
294
+ * as a generic format error instead of a specific, actionable one.
295
+ *
296
+ * Throws StackQueryError, not StackValidationError: a malformed id is
297
+ * structurally bad input the request never gets past — it doesn't reach
298
+ * type-schema validation — the same reasoning that makes an undecodable
299
+ * pagination cursor a StackQueryError rather than a content-validation
300
+ * failure. See StackQueryError's doc comment.
301
+ */
302
+ function validateRecordId(id) {
303
+ if (id.startsWith(RESERVED_ID_PREFIX)) {
304
+ throw new StackQueryError(`ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
305
+ }
306
+ if (!isValidIdFormat(id)) {
307
+ throw new StackQueryError(`Invalid ID "${id}": expected 12 lowercase Crockford base-32 characters.`);
308
+ }
309
+ }
310
+ /**
311
+ * Timestamp-prefix plausibility check for grantee-minted IDs
312
+ * (ScopedStack.create()) — a grantee is untrusted and could otherwise mint
313
+ * an ID that forges its sort position. Pass null to disable.
314
+ */
315
+ function validateIdTimestampSkew(id, toleranceMs) {
316
+ if (toleranceMs === null)
317
+ return;
318
+ const skew = Math.abs(Date.now() - idTimestamp(id));
319
+ if (skew > toleranceMs) {
320
+ throw new StackValidationError([
321
+ {
322
+ path: 'id',
323
+ message: `ID "${id}" timestamp is outside the allowed clock-skew tolerance (${toleranceMs}ms).`,
324
+ },
325
+ ]);
326
+ }
327
+ }
328
+ // -------------------------------------------------------
329
+ // Query helpers
330
+ // -------------------------------------------------------
331
+ /**
332
+ * Walks `cursor` to exhaustion for the internal call sites that need every
333
+ * match (grant checks, attachment cleanup) — query() itself always
334
+ * paginates. Throws StackQueryError past `max` rather than silently
335
+ * truncating a runaway scan. Not a public API.
336
+ */
337
+ async function queryAllPages(run, query, max = QUERY_ALL_MAX) {
338
+ const records = [];
339
+ let cursor = query.cursor;
340
+ do {
341
+ const page = await run({ ...query, cursor });
342
+ records.push(...page.records);
343
+ if (records.length > max) {
344
+ throw new StackQueryError(`queryAllPages: exceeded max of ${max} records without exhausting the cursor`);
345
+ }
346
+ cursor = page.cursor ?? undefined;
347
+ } while (cursor);
348
+ return records;
349
+ }
350
+ /** Safety cap for queryAllPages() — generous for personal-stack scale. */
351
+ const QUERY_ALL_MAX = 10_000;
352
+ /**
353
+ * Cursor-walks `run(query)` looking for the first record matching
354
+ * `predicate`, short-circuiting on a match. Same bounded-scan discipline
355
+ * as queryAllPages(): a match past page one is still found, and a
356
+ * non-terminating scan throws StackQueryError.
357
+ */
358
+ async function findFirstMatch(run, query, predicate, max = QUERY_ALL_MAX) {
359
+ let cursor = query.cursor;
360
+ let totalFetched = 0;
361
+ do {
362
+ const page = await run({ ...query, cursor });
363
+ totalFetched += page.records.length;
364
+ if (totalFetched > max) {
365
+ throw new StackQueryError(`findFirstMatch: exceeded max of ${max} records without exhausting the cursor`);
366
+ }
367
+ for (const record of page.records) {
368
+ if (await predicate(record))
369
+ return record;
370
+ }
371
+ cursor = page.cursor ?? undefined;
372
+ } while (cursor);
373
+ return undefined;
374
+ }
56
375
  // -------------------------------------------------------
57
376
  // Stack class
58
377
  // -------------------------------------------------------
59
378
  export class Stack {
60
379
  adapter;
380
+ idTimestampSkewMsValue;
61
381
  migrations = new Map();
62
- constructor(adapter) {
382
+ /**
383
+ * Highest version this instance has defineType()'d, per baseId — what
384
+ * this app process understands, as distinct from what exists in shared
385
+ * storage. Used to detect the stale-writer case in presentAtLatest().
386
+ */
387
+ maxDefinedVersion = new Map();
388
+ /**
389
+ * Types this instance has fetched or defined, keyed by versioned id. A
390
+ * Type's schema is immutable once defined, so entries are never
391
+ * invalidated, only added; listTypes() refreshes wholesale. See
392
+ * docs/spec/data-model.md § Type cache.
393
+ */
394
+ typeCache = new Map();
395
+ /** Set by close(). See docs/spec/adapters.md § Lifecycle. */
396
+ closed = false;
397
+ constructor(adapter, idTimestampSkewMsValue) {
63
398
  this.adapter = adapter;
399
+ this.idTimestampSkewMsValue = idTimestampSkewMsValue;
400
+ }
401
+ async getTypeCached(id) {
402
+ const cached = this.typeCache.get(id);
403
+ if (cached)
404
+ return cached;
405
+ const type = await this.adapter.getType(id);
406
+ if (type)
407
+ this.typeCache.set(id, type);
408
+ return type;
64
409
  }
65
410
  /**
66
411
  * Create a Stack instance. Reads ownerEntityId and timezone from the adapter.
67
412
  */
68
- static async create(adapter) {
413
+ static async create(adapter, opts = {}) {
69
414
  if (!adapter.ownerEntityId) {
70
415
  throw new Error('Stack misconfiguration: adapter has no ownerEntityId. ' +
71
416
  'Initialise the adapter with an entityId before calling Stack.create().');
72
417
  }
73
- const stack = new Stack(adapter);
418
+ const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs);
74
419
  await stack.seedSystemTypes();
420
+ if (opts.ownerProfile) {
421
+ await stack.ensureOwnerEntity(opts.ownerProfile);
422
+ }
75
423
  return stack;
76
424
  }
425
+ /**
426
+ * Idempotent bootstrap for StackOptions.ownerProfile. Filters by family
427
+ * only (contentFieldQuery is capability-gated) and matches `content.did`
428
+ * in memory, cursor-walking so an owner card past page one isn't missed
429
+ * and duplicated. The probe must be blind to nothing the binding rules
430
+ * see, or it mints a card they then refuse and the stack won't open with
431
+ * `ownerProfile`: soft-deleted cards still reserve their `did`, and a card
432
+ * migrated to a later version still holds one.
433
+ * See docs/spec/identity.md § DID bindings.
434
+ */
435
+ async ensureOwnerEntity(profile) {
436
+ const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`;
437
+ const existing = await findFirstMatch((q) => this.query(q), { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true } }, (r) => r.content.did === this.ownerEntityId);
438
+ if (existing)
439
+ return;
440
+ await this.create(entityTypeId, {
441
+ did: this.ownerEntityId,
442
+ name: profile.name,
443
+ ...(profile.handle && { handle: profile.handle }),
444
+ });
445
+ }
77
446
  get ownerEntityId() {
78
447
  return this.adapter.ownerEntityId;
79
448
  }
@@ -84,33 +453,76 @@ export class Stack {
84
453
  return this.adapter.capabilities;
85
454
  }
86
455
  /**
87
- * Get a permission-scoped view of this Stack, as if the request came from
88
- * the given entity. Pass null for an anonymous/unauthenticated requester.
89
- * Reads and writes are checked against each Record's `permissions`; the
90
- * owner always has full access.
456
+ * Get a permission-scoped view of this Stack, as if requests came from
457
+ * the given principal (null = anonymous). Plain Stack methods are
458
+ * unscoped; use asEntity() when one Stack serves multiple, possibly
459
+ * untrusted, entities.
460
+ *
461
+ * `onBehalfOf` names the subject a delegated app acts for: authority is
462
+ * then the intersection of both parties' grants, while authorship and
463
+ * `-own` resolve against the subject. See
464
+ * docs/spec/access-control.md § Enforcement: Stack.asEntity().
465
+ */
466
+ asEntity(entityId, opts = {}) {
467
+ this.assertOpen();
468
+ if (opts.onBehalfOf && !entityId) {
469
+ throw new StackPermissionError('An anonymous principal cannot act on behalf of an entity');
470
+ }
471
+ return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter);
472
+ }
473
+ /**
474
+ * Scope to an authenticated session — what StackTokenStore.lookupToken()
475
+ * returns. Equivalent to asEntity(principalId, { onBehalfOf: subjectId }),
476
+ * and what a server should reach for at its request boundary.
91
477
  *
92
- * Plain Stack methods are unscoped and skip permission checks entirely —
93
- * correct for single-entity embedded use, where there's no requester
94
- * distinct from the app itself. Use asEntity() when one Stack instance
95
- * serves requests from multiple, possibly untrusted, entities (e.g. a
96
- * multi-tenant API server).
478
+ * Both identities are DIDs, so passing them positionally leaves nothing
479
+ * to catch a swap, and a swapped pair is undetectable in the undelegated
480
+ * case where they are equal — it would surface only once delegation is in
481
+ * use, as authority no longer fenced by the app's grants and every write
482
+ * attributed to the app rather than the person. Taking the pair whole
483
+ * removes the order to get wrong. See
484
+ * docs/spec/access-control.md § Delegation: principal and subject.
97
485
  */
98
- asEntity(entityId) {
99
- return new ScopedStack(this, entityId);
486
+ forSession(session) {
487
+ return this.asEntity(session.principalId, { onBehalfOf: session.subjectId });
100
488
  }
101
489
  // -------------------------------------------------------
102
490
  // Types
103
491
  // -------------------------------------------------------
104
492
  /**
105
- * Define and persist a new Type. Computes the schemaHash automatically.
106
- * Should be called at app startup before creating any records of this type.
493
+ * Define and persist a Type; call at app startup before creating records
494
+ * of the type. Redefining an existing typeId is checked against the
495
+ * stored schema: identical is a no-op, a name-only change persists, and
496
+ * anything beyond additive evolution throws StackSchemaDriftError. See
497
+ * docs/spec/data-model.md § Schema drift detection.
107
498
  */
108
499
  async defineType(id, name, schema, opts = {}) {
500
+ this.assertOpen();
109
501
  const parsed = parseTypeId(id);
110
502
  if (!parsed) {
111
503
  throw new Error(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
112
504
  }
505
+ // Register this version even on the idempotent-no-op path —
506
+ // presentAtLatest()'s stale-writer detection depends on it.
507
+ const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
508
+ if (parsed.version > priorMax)
509
+ this.maxDefinedVersion.set(parsed.baseId, parsed.version);
113
510
  const schemaHash = await hashSchema(schema);
511
+ const existing = await this.getTypeCached(id);
512
+ if (existing) {
513
+ if (existing.schemaHash === schemaHash) {
514
+ if (existing.name === name)
515
+ return existing;
516
+ // else: name-only change — falls through to the write below,
517
+ // schema/hash/createdAt all carried over unchanged.
518
+ }
519
+ else {
520
+ const violations = diffSchemas(existing.schema, schema);
521
+ if (violations.length > 0) {
522
+ throw new StackSchemaDriftError(id, violations);
523
+ }
524
+ }
525
+ }
114
526
  const type = {
115
527
  id,
116
528
  baseId: parsed.baseId,
@@ -118,24 +530,32 @@ export class Stack {
118
530
  name,
119
531
  schema,
120
532
  schemaHash,
121
- createdAt: new Date(),
533
+ createdAt: existing?.createdAt ?? new Date(),
122
534
  ...(opts.migratesFrom && { migratesFrom: opts.migratesFrom }),
123
535
  };
124
536
  await this.adapter.saveType(type);
537
+ this.typeCache.set(id, type);
125
538
  return type;
126
539
  }
127
540
  async getType(id) {
128
- return this.adapter.getType(id);
541
+ this.assertOpen();
542
+ return this.getTypeCached(id);
129
543
  }
544
+ /** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */
130
545
  async listTypes() {
131
- return this.adapter.listTypes();
546
+ this.assertOpen();
547
+ const types = await this.adapter.listTypes();
548
+ for (const type of types)
549
+ this.typeCache.set(type.id, type);
550
+ return types;
132
551
  }
133
552
  /**
134
553
  * Check whether a record's type is compatible with a required schema.
135
554
  * Useful for duck-typed consumption across types.
136
555
  */
137
556
  async typeIsCompatible(typeId, requiredSchema) {
138
- const type = await this.adapter.getType(typeId);
557
+ this.assertOpen();
558
+ const type = await this.getTypeCached(typeId);
139
559
  if (!type)
140
560
  return false;
141
561
  return isCompatible(type.schema, requiredSchema);
@@ -144,14 +564,13 @@ export class Stack {
144
564
  // Migration registry
145
565
  // -------------------------------------------------------
146
566
  /**
147
- * Register a migration function between two adjacent Type versions.
148
- * Call at app startup after defineType(). The library composes
149
- * adjacent migrations into chains automatically.
150
- *
151
- * Migrations run in-memory — they do not write to the adapter unless
152
- * you call migrateAll() explicitly.
567
+ * Register a migration function between two adjacent Type versions; call
568
+ * at app startup after defineType(). Runs in-memory — nothing is written
569
+ * until migrateAll(). Adjacent migrations are composed into chains
570
+ * automatically.
153
571
  */
154
572
  registerMigration(migration) {
573
+ this.assertOpen();
155
574
  if (this.migrations.has(migration.from)) {
156
575
  throw new StackMigrationError(`A migration from "${migration.from}" is already registered.`);
157
576
  }
@@ -197,17 +616,13 @@ export class Stack {
197
616
  return current;
198
617
  }
199
618
  /**
200
- * Eagerly migrate all records of a type family to the latest version,
201
- * committing the results to disk immediately.
202
- *
203
- * Normally migration is lazy — records are migrated in memory on read
204
- * and written back only when next updated. Call migrateAll() when you
205
- * want to commit all pending migrations in one deliberate pass, for
206
- * example before a deployment or after a major schema change.
207
- *
208
- * Previous content is preserved in version history before each write.
619
+ * Eagerly migrate all records of a type family to the latest version —
620
+ * the only way disk state changes version. Sweeps soft-deleted records
621
+ * too, validates each result before writing, and aborts on the first
622
+ * validation failure. See docs/spec/data-model.md § Type migrations.
209
623
  */
210
624
  async migrateAll(baseTypeId) {
625
+ this.assertOpen();
211
626
  const types = await this.adapter.listTypes();
212
627
  const familyTypeIds = types.filter((t) => t.baseId === baseTypeId).map((t) => t.id);
213
628
  if (familyTypeIds.length === 0) {
@@ -221,22 +636,23 @@ export class Stack {
221
636
  const migrateFn = this.resolveMigrationPath(typeId, latestId);
222
637
  if (!migrateFn)
223
638
  continue;
639
+ const latestType = await this.getTypeCached(latestId);
640
+ if (!latestType) {
641
+ throw new StackMigrationError(`migrateAll: target type "${latestId}" is not defined.`);
642
+ }
224
643
  let cursor;
225
644
  do {
226
645
  const result = await this.adapter.queryRecords({
227
- filter: { typeId },
646
+ filter: { typeId, includeDeleted: true },
228
647
  limit: 100,
229
648
  cursor,
230
649
  });
231
650
  for (const record of result.records) {
232
- await this.saveVersion(record);
233
- const migratedContent = migrateFn(record.content);
234
- await this.adapter.updateRecord(record.id, {
235
- typeId: latestId,
236
- content: migratedContent,
237
- updatedAt: new Date(),
238
- version: record.version + 1,
239
- });
651
+ // Same checked path commitMigration() takes — a migration
652
+ // function is no more entitled to move a DID binding or repoint
653
+ // an attachment than a request body is. No ifVersion: a batch
654
+ // pass doesn't know each record's version going in.
655
+ await this.commitMigrationChecked(record, latestId, migrateFn(record.content));
240
656
  migrated++;
241
657
  }
242
658
  cursor = result.cursor ?? undefined;
@@ -249,19 +665,33 @@ export class Stack {
249
665
  // -------------------------------------------------------
250
666
  /**
251
667
  * Create a new record. Validates content against the type's schema.
668
+ * `_group` records get their author stamped as the first `admin` roster
669
+ * association here — the single stamping site for both Stack.create()
670
+ * and ScopedStack.create(). See docs/spec/identity.md § Group.
252
671
  */
253
672
  async create(typeId, content, opts = {}) {
254
- const type = await this.adapter.getType(typeId);
673
+ this.assertOpen();
674
+ const type = await this.getTypeCached(typeId);
255
675
  if (!type) {
256
676
  throw new Error(`Unknown type: "${typeId}". Call defineType() first.`);
257
677
  }
258
- const errors = validateContent(content, type.schema);
678
+ const errors = [...validateReservedKeys(content), ...validateContent(content, type.schema)];
259
679
  if (errors.length > 0) {
260
680
  throw new StackValidationError(errors);
261
681
  }
682
+ assertContentSize(content, this.features.maxContentBytes, 'Content');
683
+ if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
684
+ await this.checkAttachmentMimeTypeOnCreate(content);
685
+ }
686
+ await this.checkBindingsOnCreate(typeId, content);
687
+ if (opts.id !== undefined)
688
+ validateRecordId(opts.id);
689
+ const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
690
+ ? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
691
+ : opts.associations;
262
692
  const now = new Date();
263
693
  const record = {
264
- id: generateId(),
694
+ id: opts.id ?? generateId(),
265
695
  typeId,
266
696
  createdAt: now,
267
697
  updatedAt: now,
@@ -270,284 +700,937 @@ export class Stack {
270
700
  ...(opts.parentId && { parentId: opts.parentId }),
271
701
  ...(opts.entityId && { entityId: opts.entityId }),
272
702
  ...(opts.appId && { appId: opts.appId }),
703
+ ...(opts.principalId && { principalId: opts.principalId }),
273
704
  ...(opts.permissions?.length && { permissions: opts.permissions }),
274
- ...(opts.associations?.length && { associations: opts.associations }),
705
+ ...(associations?.length && { associations }),
275
706
  };
276
707
  return this.adapter.createRecord(record);
277
708
  }
278
709
  /**
279
- * Get a record by ID.
280
- *
281
- * If the record is on an older type version and a migration path is
282
- * registered, the content is migrated in memory and returned at the
283
- * latest type version — but nothing is written to disk. Migration is
284
- * only persisted when the record is next updated via update().
285
- *
286
- * Pass { migrate: false } to get the raw stored record without migration.
710
+ * Apply the registered migration chain in memory, for presentAt:
711
+ * 'latest'. Never writes back. Throws StackMigrationError when the
712
+ * record's version can't be reconciled with what this instance has
713
+ * registered — the stale-writer case. See docs/spec/data-model.md
714
+ * § Type migrations.
715
+ */
716
+ presentAtLatest(record) {
717
+ const latestId = this.latestTypeId(record.typeId);
718
+ if (latestId !== record.typeId) {
719
+ // latestTypeId() found this by walking the same migration graph
720
+ // resolveMigrationPath() walks, from the same starting point, so a
721
+ // path is always resolvable here.
722
+ const migrateFn = this.resolveMigrationPath(record.typeId, latestId);
723
+ return { ...record, typeId: latestId, content: migrateFn(record.content) };
724
+ }
725
+ const parsed = parseTypeId(record.typeId);
726
+ const knownMax = parsed ? this.maxDefinedVersion.get(parsed.baseId) : undefined;
727
+ if (parsed && knownMax !== undefined && parsed.version !== knownMax) {
728
+ const direction = parsed.version > knownMax
729
+ ? `the record is newer than this app instance understands — update the app, or register the missing migrations`
730
+ : `no registered migration bridges the gap — call stack.registerMigration()`;
731
+ throw new StackMigrationError(`Record "${record.id}" is at "${record.typeId}", but this app instance has defined ` +
732
+ `up to "${parsed.baseId}@${knownMax}": ${direction}. Omit presentAt: "latest" to ` +
733
+ `read the record as stored.`);
734
+ }
735
+ return record;
736
+ }
737
+ /**
738
+ * Get a record by ID, exactly as stored — no implicit migration. Pass
739
+ * { presentAt: 'latest' } to migrate in memory; only migrateAll()
740
+ * commits migrations to disk.
287
741
  */
288
742
  async get(id, opts = {}) {
743
+ this.assertOpen();
289
744
  const record = await this.adapter.getRecord(id);
290
745
  if (!record)
291
746
  return null;
292
- const shouldMigrate = opts.migrate !== false;
293
- if (!shouldMigrate)
294
- return record;
295
- const latestId = this.latestTypeId(record.typeId);
296
- if (latestId === record.typeId)
297
- return record;
298
- const migrateFn = this.resolveMigrationPath(record.typeId, latestId);
299
- if (!migrateFn) {
300
- console.warn(`[Stack] No migration path from "${record.typeId}" to "${latestId}" for record "${id}". ` +
301
- `Returning raw record. Register migrations with stack.registerMigration().`);
302
- return record;
303
- }
304
- return {
305
- ...record,
306
- typeId: latestId,
307
- content: migrateFn(record.content),
308
- };
747
+ return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
309
748
  }
310
749
  /**
311
- * Update a record's content. Accepts a partial content object — only the
312
- * fields provided are changed. Omitted fields retain their current values.
313
- *
314
- * To remove an optional field, set it explicitly to null:
315
- * stack.update(id, { title: null }) // removes 'title' from content
316
- *
317
- * If the record is on an older type version, its content is migrated
318
- * in memory first, then the patch is applied. The updated record is
319
- * written at the latest type version — this is when lazy migration
320
- * is committed to disk.
321
- *
322
- * Validates the merged result against the type's schema. Saves the
323
- * previous state to version history before updating.
324
- *
325
- * For association or permission changes, use associate(), dissociate(),
326
- * and setPermissions() instead.
750
+ * Update a record's content via JSON Merge Patch: omitted fields are
751
+ * kept, null removes a field. Validates the merged result against the
752
+ * record's *current* stored type and never changes typeId (see
753
+ * docs/spec/data-model.md § Type migrations); snapshots the prior state
754
+ * to version history. Associations and permissions have their own methods.
327
755
  */
328
- async update(id, content) {
756
+ async update(id, content, opts = {}) {
757
+ this.assertOpen();
329
758
  const existing = await this.adapter.getRecord(id);
330
759
  if (!existing) {
331
760
  throw new StackNotFoundError(`Record not found: "${id}"`);
332
761
  }
333
- // Resolve the latest type version and migrate existing content
334
- // in memory before applying the patch. This is when lazy migration
335
- // from get() gets committed to disk.
336
- const latestTypeId = this.latestTypeId(existing.typeId);
337
- const migrateFn = this.resolveMigrationPath(existing.typeId, latestTypeId);
338
- const existingContent = migrateFn ? migrateFn(existing.content) : existing.content;
339
- const type = await this.adapter.getType(latestTypeId);
762
+ this.checkIfVersion(existing, opts.ifVersion);
763
+ const type = await this.getTypeCached(existing.typeId);
340
764
  if (!type) {
341
- throw new Error(`Unknown type: "${latestTypeId}"`);
342
- }
343
- // Shallow merge: start with (migrated) existing content, apply changes.
344
- // null values mean "delete this field" (RFC 7396 / JSON Merge Patch).
345
- const merged = { ...existingContent };
346
- for (const [key, value] of Object.entries(content)) {
347
- if (value === null) {
348
- delete merged[key];
349
- }
350
- else {
351
- merged[key] = value;
352
- }
765
+ throw new Error(`Unknown type: "${existing.typeId}"`);
766
+ }
767
+ // Checked on the raw patch, before the merge: a reserved key in a
768
+ // patch is lost by applyMergePatch rather than stored, so a check on
769
+ // the merged result would never see it. See validateReservedKeys().
770
+ const patchErrors = validateReservedKeys(content);
771
+ if (patchErrors.length > 0) {
772
+ throw new StackValidationError(patchErrors);
353
773
  }
774
+ // The patch is what travels, so the patch is what's measured — a small
775
+ // patch against a large record is not an oversized request.
776
+ assertContentSize(content, this.features.maxContentBytes, 'Patch');
777
+ // Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
778
+ const merged = applyMergePatch(existing.content, content);
354
779
  const errors = validateContent(merged, type.schema);
355
780
  if (errors.length > 0) {
356
781
  throw new StackValidationError(errors);
357
782
  }
358
- // Snapshot the raw stored state before overwriting
359
- await this.saveVersion(existing);
360
- return this.adapter.updateRecord(id, {
361
- typeId: latestTypeId,
362
- content: merged,
363
- updatedAt: new Date(),
364
- version: existing.version + 1,
783
+ if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
784
+ this.checkAttachmentImmutableFields(content, existing.content, merged);
785
+ }
786
+ await this.checkBindingsOnUpdate(existing.typeId, id, content, existing.content, merged);
787
+ if (id === SYSTEM_TYPES.CONFIG) {
788
+ this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
789
+ }
790
+ return this.adapter.patchContent(id, content, {
791
+ expectedVersion: opts.ifVersion,
792
+ snapshot: this.buildVersionSnapshot(existing),
365
793
  });
366
794
  }
367
795
  /**
368
- * Add an association to a record.
796
+ * Add an association to a record. Snapshots the record's prior state and
797
+ * bumps version, same as update() — associations are covered by the same
798
+ * versioning rule as content.
369
799
  * If the association already exists (same kind, label, and payload), this is a no-op.
370
800
  */
371
- async associate(id, association) {
372
- return this.adapter.associate(id, association);
801
+ async associate(id, association, opts = {}) {
802
+ this.assertOpen();
803
+ const existing = await this.adapter.getRecord(id);
804
+ if (!existing) {
805
+ throw new StackNotFoundError(`Record not found: "${id}"`);
806
+ }
807
+ this.checkIfVersion(existing, opts.ifVersion);
808
+ if ((existing.associations ?? []).some((a) => associationEqual(a, association)))
809
+ return;
810
+ await this.adapter.associate(id, association, {
811
+ expectedVersion: opts.ifVersion,
812
+ snapshot: this.buildVersionSnapshot(existing),
813
+ });
373
814
  }
374
815
  /**
375
- * Remove an association from a record.
376
- * Matched by kind, label, and payload. No-op if not found.
816
+ * Remove an association from a record. Snapshots and bumps version, same
817
+ * as associate(). Matched by kind, label, and payload. No-op if not found.
377
818
  */
378
- async dissociate(id, association) {
379
- return this.adapter.dissociate(id, association);
819
+ async dissociate(id, association, opts = {}) {
820
+ this.assertOpen();
821
+ const existing = await this.adapter.getRecord(id);
822
+ if (!existing) {
823
+ throw new StackNotFoundError(`Record not found: "${id}"`);
824
+ }
825
+ this.checkIfVersion(existing, opts.ifVersion);
826
+ if (!(existing.associations ?? []).some((a) => associationEqual(a, association)))
827
+ return;
828
+ await this.adapter.dissociate(id, association, {
829
+ expectedVersion: opts.ifVersion,
830
+ snapshot: this.buildVersionSnapshot(existing),
831
+ });
380
832
  }
381
833
  /**
382
- * Replace all permissions on a record.
383
- * Pass an empty array to make the record private (the default).
834
+ * Replace all permissions on a record. Snapshots and bumps version, same
835
+ * as associate(). Pass an empty array to make the record private (the
836
+ * default). No-op if the new set is deep-equal to the current one.
384
837
  */
385
- async setPermissions(id, permissions) {
838
+ async setPermissions(id, permissions, opts = {}) {
839
+ this.assertOpen();
386
840
  const existing = await this.adapter.getRecord(id);
387
841
  if (!existing) {
388
842
  throw new StackNotFoundError(`Record not found: "${id}"`);
389
843
  }
390
- await this.adapter.updateRecord(id, { permissions });
844
+ this.checkIfVersion(existing, opts.ifVersion);
845
+ if (permissionsEqual(existing.permissions ?? [], permissions))
846
+ return;
847
+ await this.adapter.setPermissions(id, permissions, {
848
+ expectedVersion: opts.ifVersion,
849
+ snapshot: this.buildVersionSnapshot(existing),
850
+ });
391
851
  }
392
852
  /**
393
- * Soft-delete a record (default) or hard-delete it permanently.
394
- * Soft-deleted records are excluded from queries unless includeDeleted is set.
395
- * Hard-deleted records and all their version history are permanently removed.
853
+ * Soft-delete a record (default) or hard-delete it permanently, removing
854
+ * the record and its version history. Soft delete snapshots and bumps
855
+ * version; a no-op if already deleted. `_config` is never deletable
856
+ * (docs/spec.md § The `_config` record). See docs/spec/versioning.md
857
+ * § Deletion.
396
858
  */
397
859
  async delete(id, opts = {}) {
398
- return this.adapter.deleteRecord(id, opts);
860
+ this.assertOpen();
861
+ if (id === SYSTEM_TYPES.CONFIG) {
862
+ throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
863
+ }
864
+ if (opts.hard) {
865
+ return this.adapter.deleteRecord(id, { hard: true, expectedVersion: opts.ifVersion });
866
+ }
867
+ const existing = await this.adapter.getRecord(id);
868
+ if (!existing) {
869
+ throw new StackNotFoundError(`Record not found: "${id}"`);
870
+ }
871
+ this.checkIfVersion(existing, opts.ifVersion);
872
+ if (existing.deletedAt)
873
+ return;
874
+ await this.adapter.deleteRecord(id, {
875
+ expectedVersion: opts.ifVersion,
876
+ snapshot: this.buildVersionSnapshot(existing),
877
+ });
878
+ }
879
+ /**
880
+ * Reverse a soft delete. Idempotent — undeleting a record that isn't
881
+ * deleted returns it unchanged. Hard-deleted records are gone, so this
882
+ * throws StackNotFoundError for them just like any other missing record.
883
+ * Snapshots and bumps version, same as delete().
884
+ */
885
+ async undelete(id, opts = {}) {
886
+ this.assertOpen();
887
+ const existing = await this.adapter.getRecord(id);
888
+ if (!existing) {
889
+ throw new StackNotFoundError(`Record not found: "${id}"`);
890
+ }
891
+ this.checkIfVersion(existing, opts.ifVersion);
892
+ if (!existing.deletedAt)
893
+ return existing;
894
+ return this.adapter.undeleteRecord(id, {
895
+ expectedVersion: opts.ifVersion,
896
+ snapshot: this.buildVersionSnapshot(existing),
897
+ });
399
898
  }
400
899
  /**
401
- * Query records. See StackQuery for filter, sort, and pagination options.
900
+ * Query records. filter.baseId matches every version of a type family,
901
+ * resolved against registered Types. Results come back exactly as
902
+ * stored; pass presentAt: 'latest' to migrate in memory. See
903
+ * docs/spec/data-model.md § Queries.
402
904
  */
403
905
  async query(query = {}) {
404
- const limit = query.limit !== undefined ? Math.min(query.limit, MAX_QUERY_LIMIT) : undefined;
405
- return this.adapter.queryRecords(limit !== undefined ? { ...query, limit } : query);
906
+ this.assertOpen();
907
+ const { presentAt, filter, limit: rawLimit, ...rest } = query;
908
+ assertQueryCapabilities(filter, this.adapter.capabilities);
909
+ const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
910
+ const resolvedFilter = await this.resolveBaseIdFilter(filter);
911
+ if (resolvedFilter === EMPTY_FAMILY) {
912
+ return { records: [], cursor: null, total: 0 };
913
+ }
914
+ const result = await this.adapter.queryRecords({
915
+ ...rest,
916
+ ...(resolvedFilter !== undefined && { filter: resolvedFilter }),
917
+ ...(limit !== undefined && { limit }),
918
+ });
919
+ if (presentAt !== 'latest')
920
+ return result;
921
+ return { ...result, records: result.records.map((r) => this.presentAtLatest(r)) };
922
+ }
923
+ /**
924
+ * Resolve filter.baseId into a concrete typeId set (intersected with
925
+ * filter.typeId when both are given), so adapters never need their own
926
+ * baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
927
+ * the caller can short-circuit without an adapter round trip.
928
+ */
929
+ async resolveBaseIdFilter(filter) {
930
+ if (filter?.baseId === undefined)
931
+ return filter;
932
+ const { baseId, typeId, ...rest } = filter;
933
+ const requestedBaseIds = Array.isArray(baseId) ? baseId : [baseId];
934
+ const types = await this.adapter.listTypes();
935
+ const familyTypeIds = types.filter((t) => requestedBaseIds.includes(t.baseId)).map((t) => t.id);
936
+ const resolvedTypeIds = typeId === undefined
937
+ ? familyTypeIds
938
+ : familyTypeIds.filter((id) => Array.isArray(typeId) ? typeId.includes(id) : typeId === id);
939
+ if (resolvedTypeIds.length === 0)
940
+ return EMPTY_FAMILY;
941
+ return { ...rest, typeId: resolvedTypeIds };
406
942
  }
407
943
  // -------------------------------------------------------
408
944
  // Versions
409
945
  // -------------------------------------------------------
410
946
  async getVersions(id) {
947
+ this.assertOpen();
411
948
  return this.adapter.getVersions(id);
412
949
  }
413
950
  async getVersion(id, version) {
951
+ this.assertOpen();
414
952
  return this.adapter.getVersion(id, version);
415
953
  }
416
954
  /**
417
- * Restore a record to a previous version by creating a new version
418
- * with the old content. Never rewrites history.
955
+ * Restore a record to a previous version by creating a new version —
956
+ * never rewrites history. The snapshot is validated against its own
957
+ * stored typeId (not the record's current type), restores associations,
958
+ * and never restores permissions. See docs/spec/versioning.md § Restore
959
+ * semantics.
419
960
  */
420
- async restoreVersion(id, version) {
961
+ async restoreVersion(id, version, opts = {}) {
962
+ this.assertOpen();
421
963
  const existing = await this.adapter.getRecord(id);
422
964
  if (!existing) {
423
965
  throw new StackNotFoundError(`Record not found: "${id}"`);
424
966
  }
967
+ this.checkIfVersion(existing, opts.ifVersion);
425
968
  const target = await this.adapter.getVersion(id, version);
426
969
  if (!target) {
427
970
  throw new StackNotFoundError(`Version ${version} not found for record "${id}"`);
428
971
  }
429
- // Snapshot current state before restoring
430
- await this.saveVersion(existing);
431
- return this.adapter.updateRecord(id, {
432
- content: target.content,
433
- updatedAt: new Date(),
434
- version: existing.version + 1,
972
+ const type = await this.getTypeCached(target.typeId);
973
+ if (!type) {
974
+ throw new Error(`Unknown type: "${target.typeId}"`);
975
+ }
976
+ const errors = validateContent(target.content, type.schema);
977
+ if (errors.length > 0) {
978
+ throw new StackValidationError(errors);
979
+ }
980
+ if (id === SYSTEM_TYPES.CONFIG) {
981
+ this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
982
+ }
983
+ // Restoring is a write like any other, so it owes the same immutability
984
+ // check update() pays — a snapshot taken before a card claimed its DID
985
+ // would otherwise move the binding by rolling content back. Uniqueness
986
+ // needs no separate check here: a restore can only put back a value this
987
+ // same card already held, which immutability already refuses to change.
988
+ for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
989
+ this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
990
+ }
991
+ return this.adapter.restoreVersion(id, version, {
992
+ expectedVersion: opts.ifVersion,
993
+ snapshot: this.buildVersionSnapshot(existing),
435
994
  });
436
995
  }
437
- // -------------------------------------------------------
438
- // Attachments
439
- // -------------------------------------------------------
440
996
  /**
441
- * Store raw bytes and return the content-addressed file ID.
442
- * Does not create an _attachment@1 record — use putAttachment() or
443
- * ScopedStack.putAttachment() for the full upload flow.
997
+ * Commit a per-record migration: replace `content` and `typeId` together
998
+ * in one step, validated against `toTypeId`'s schema exactly as
999
+ * create()/update() validate against a type's schema. The single-record
1000
+ * counterpart to migrateAll() — content here is supplied by the caller
1001
+ * (computed client-side by the type's owning app, per
1002
+ * docs/spec/wire-format.md § Migration commit) rather than a registered
1003
+ * Migration function. Snapshots the prior state to version history, same
1004
+ * as update()/restoreVersion(), and takes the same optional `ifVersion`
1005
+ * precondition every version-bumping mutation takes — checked atomically
1006
+ * at the adapter, not here (see docs/spec/versioning.md § Optimistic
1007
+ * concurrency).
1008
+ *
1009
+ * Because `content` is a full replacement written under a new `typeId`,
1010
+ * this is create-shaped at the destination *and* update-shaped over the
1011
+ * record as it stands, so it owes both sets of integrity checks — the
1012
+ * binding rules, the attachment rules, and `_config`'s. Missing either
1013
+ * half would make migrate a second, unguarded write path to the same
1014
+ * state create()/update() refuse to reach.
444
1015
  */
445
- async putAttachmentBytes(data) {
446
- return this.adapter.putAttachment(data);
1016
+ async commitMigration(id, toTypeId, content, opts = {}) {
1017
+ this.assertOpen();
1018
+ const existing = await this.adapter.getRecord(id);
1019
+ if (!existing) {
1020
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1021
+ }
1022
+ this.checkIfVersion(existing, opts.ifVersion);
1023
+ return this.commitMigrationChecked(existing, toTypeId, content, opts.ifVersion);
447
1024
  }
448
1025
  /**
449
- * Store bytes and create an _attachment@1 metadata record (owner-attributed,
450
- * no entityId). Use ScopedStack.putAttachment() when the uploader is a
451
- * specific entity rather than the stack owner.
1026
+ * The checked migration write, shared by commitMigration() and
1027
+ * migrateAll(). Takes the record already in hand rather than an id: a
1028
+ * batch pass holds each record from its own query page, and re-fetching
1029
+ * per record would cost a read apiece for nothing.
1030
+ *
1031
+ * Both callers owe the same checks. migrateAll()'s content comes from a
1032
+ * registered Migration function rather than a request body, but "app
1033
+ * code" is not a trust boundary here — the app calling commitMigration()
1034
+ * is the same app that registered the function, and neither may move a
1035
+ * DID binding or repoint an attachment. registerMigration() also places
1036
+ * no constraint on `from` and `to` sharing a baseId, so a migration path
1037
+ * can cross type families; family-crossing is exactly what the checks
1038
+ * below care about.
452
1039
  */
453
- async putAttachment(data, mimeType, filename) {
454
- const fileId = await this.putAttachmentBytes(data);
455
- await this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
456
- fileId,
457
- mimeType,
458
- size: data.byteLength,
459
- ...(filename && { filename }),
1040
+ async commitMigrationChecked(existing, toTypeId, content, ifVersion) {
1041
+ const id = existing.id;
1042
+ const type = await this.getTypeCached(toTypeId);
1043
+ if (!type) {
1044
+ throw new Error(`Unknown type: "${toTypeId}". Call defineType() first.`);
1045
+ }
1046
+ const errors = [...validateReservedKeys(content), ...validateContent(content, type.schema)];
1047
+ if (errors.length > 0) {
1048
+ throw new StackValidationError(errors);
1049
+ }
1050
+ assertContentSize(content, this.features.maxContentBytes, 'Content');
1051
+ const fromFamily = baseIdOf(existing.typeId);
1052
+ const toFamily = baseIdOf(toTypeId);
1053
+ const existingContent = existing.content;
1054
+ // A `_group` record's `admin` roster entry is stamped by create(), the
1055
+ // single site that does it — migrate cannot, since the adapter's
1056
+ // commitMigration() writes `typeId` and `content` alone and leaves
1057
+ // associations untouched. Minting one here would produce a group with
1058
+ // an empty roster, manageable by nobody but the owner, so migrating
1059
+ // *into* the family is refused. Version-to-version stays open and
1060
+ // carries the existing roster with it.
1061
+ if (toFamily === SYSTEM_TYPES.GROUP && fromFamily !== SYSTEM_TYPES.GROUP) {
1062
+ throw new StackConflictError('Cannot migrate a record into _group: a group’s admin roster is stamped at creation. ' +
1063
+ 'Create the group instead.');
1064
+ }
1065
+ if (fromFamily === SYSTEM_TYPES.ATTACHMENT) {
1066
+ this.checkAttachmentImmutableOnMigrate(existingContent, content);
1067
+ }
1068
+ else if (toFamily === SYSTEM_TYPES.ATTACHMENT) {
1069
+ // A record arriving from outside the family stakes a fresh claim on
1070
+ // its fileId, exactly as create() does — so it owes create()'s check.
1071
+ await this.checkAttachmentMimeTypeOnCreate(content);
1072
+ }
1073
+ await this.checkBindingsOnMigrate(existing.typeId, toTypeId, id, existingContent, content);
1074
+ if (id === SYSTEM_TYPES.CONFIG) {
1075
+ this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
1076
+ }
1077
+ return this.adapter.commitMigration(id, toTypeId, content, {
1078
+ expectedVersion: ifVersion,
1079
+ snapshot: this.buildVersionSnapshot(existing),
460
1080
  });
461
- return fileId;
462
1081
  }
463
- async getAttachment(fileId) {
464
- return this.adapter.getAttachment(fileId);
1082
+ /** Uniqueness for every unique binding field a newly created card claims. */
1083
+ async checkBindingsOnCreate(typeId, content) {
1084
+ const family = baseIdOf(typeId);
1085
+ for (const field of uniqueBindingFieldsOf(family)) {
1086
+ await this.checkBindingUnique(family, field, content[field]);
1087
+ }
465
1088
  }
466
1089
  /**
467
- * Delete an attachment's bytes and its _attachment@1 metadata record(s).
468
- * Throws StackConflictError if any record in the stack still references the file.
469
- * Throws StackNotFoundError if neither metadata records nor bytes exist.
1090
+ * Immutability for every binding field a patch touches, then uniqueness
1091
+ * for the subset that carries it. Fields absent from the patch carry no
1092
+ * new claim — update() is a merge, so an untouched binding is the one the
1093
+ * card already holds.
470
1094
  */
471
- async deleteAttachment(fileId) {
472
- const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
473
- if (refResult.records.length > 0) {
474
- throw new StackConflictError('Attachment is still referenced by one or more records');
475
- }
476
- const metaResult = await this.query({
477
- filter: {
478
- typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
479
- ...(this.features.contentFieldQuery && { content: { fileId } }),
480
- },
481
- });
482
- const metaRecords = this.features.contentFieldQuery
483
- ? metaResult.records
484
- : metaResult.records.filter((r) => r.content.fileId === fileId);
485
- if (!metaRecords.length) {
486
- try {
487
- await this.adapter.getAttachment(fileId);
1095
+ async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
1096
+ const family = baseIdOf(typeId);
1097
+ const unique = uniqueBindingFieldsOf(family);
1098
+ for (const field of bindingFieldsOf(family)) {
1099
+ if (!(field in patch))
1100
+ continue;
1101
+ this.checkBindingImmutable(family, field, existing[field], merged[field]);
1102
+ if (unique.includes(field)) {
1103
+ await this.checkBindingUnique(family, field, merged[field], id);
488
1104
  }
489
- catch {
490
- throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
1105
+ }
1106
+ }
1107
+ /**
1108
+ * Bindings across a migration. `content` is a full replacement rather
1109
+ * than a patch, so there is no "field absent from the patch" case to
1110
+ * exempt: every binding field either keeps its value, moves to a new
1111
+ * one, or is shed by omission — and immutability refuses the last two
1112
+ * whichever family they happen in. Asked across the union of the two
1113
+ * families' binding fields, so a card can neither shed its DID by
1114
+ * migrating out of `_entity`/`_app` nor pick one up on the way in.
1115
+ *
1116
+ * Uniqueness is asked only of the destination family, which is where the
1117
+ * record's claim lives once the write lands, and excludes the record
1118
+ * itself — re-sending the value it already holds claims nothing.
1119
+ * See docs/spec/identity.md § DID bindings.
1120
+ */
1121
+ async checkBindingsOnMigrate(fromTypeId, toTypeId, id, existing, content) {
1122
+ const fromFamily = baseIdOf(fromTypeId);
1123
+ const toFamily = baseIdOf(toTypeId);
1124
+ const checked = new Set();
1125
+ for (const family of fromFamily === toFamily ? [fromFamily] : [fromFamily, toFamily]) {
1126
+ for (const field of bindingFieldsOf(family)) {
1127
+ if (checked.has(field))
1128
+ continue;
1129
+ checked.add(field);
1130
+ this.checkBindingImmutable(family, field, existing[field], content[field]);
491
1131
  }
492
1132
  }
493
- for (const record of metaRecords) {
494
- await this.delete(record.id, { hard: true });
1133
+ for (const field of uniqueBindingFieldsOf(toFamily)) {
1134
+ await this.checkBindingUnique(toFamily, field, content[field], id);
495
1135
  }
496
- await this.adapter.deleteAttachment(fileId);
497
1136
  }
498
- // -------------------------------------------------------
499
- // Lifecycle
500
- // -------------------------------------------------------
501
1137
  /**
502
- * Flush any pending writes to the underlying storage.
503
- * For adapters that write immediately (SQLite, JSON), this is a no-op.
504
- * For the API adapter, this commits the offline write queue to the server.
505
- * Safe to call at any time — always resolves, never rejects on its own.
1138
+ * A unique binding field is what a lookup resolves *by* — a record's
1139
+ * `principalId` by `_app.did`, its `entityId` by `_entity.did`. Two cards
1140
+ * claiming one value would leave that lookup without a single answer, and
1141
+ * ambiguity is all an impersonating card needs. Enforced here rather than
1142
+ * by schema, since uniqueness is a property of the set, not of the value.
1143
+ * Called by the paths that can introduce a binding: create and update.
1144
+ *
1145
+ * Short-circuits on the first clash rather than materialising the family,
1146
+ * so a stack whose `_entity` family is larger than a single scan settles
1147
+ * the common case — the value is already taken — without walking the rest.
1148
+ *
1149
+ * Read-then-write, so two creates racing on one value can both pass.
1150
+ * Closing that properly means a unique index over a JSON field, which
1151
+ * each adapter would enforce separately — a decision about where
1152
+ * uniqueness lives, not a local fix.
1153
+ * See docs/spec/identity.md § DID bindings.
506
1154
  */
507
- async flush() {
508
- await this.adapter.flush?.();
1155
+ async checkBindingUnique(family, field, value, excludeId) {
1156
+ if (typeof value !== 'string' || value === '')
1157
+ return;
1158
+ const clash = await findFirstMatch((q) => this.query(q), {
1159
+ filter: {
1160
+ baseId: family,
1161
+ includeDeleted: true,
1162
+ ...(this.features.contentFieldQuery && { content: { [field]: value } }),
1163
+ },
1164
+ }, (r) => r.id !== excludeId && r.content[field] === value);
1165
+ if (clash) {
1166
+ throw new StackConflictError(`Another ${family} record already claims the ${field} "${value}"`);
1167
+ }
509
1168
  }
510
1169
  /**
511
- * Release any resources held by the adapter (connections, file handles, timers).
512
- * Call this when the stack is no longer needed — especially important for the
513
- * API adapter, which holds an open connection and retry timers.
514
- * Safe to call even if the adapter has no resources to release.
1170
+ * A binding is permanent once made: uniqueness stops a second card
1171
+ * claiming a value, but only immutability stops an existing card being
1172
+ * moved onto one, which reaches the same impersonation by another route.
1173
+ * Adopting a value is therefore a one-way step, and a subject whose key
1174
+ * changes gets a new card — matching identity.md's deferral of key
1175
+ * rotation, where a new key is a new identity rather than the same one
1176
+ * relabelled. See docs/spec/identity.md § DID bindings.
515
1177
  */
516
- async close() {
517
- await this.adapter.close?.();
1178
+ checkBindingImmutable(family, field, existing, next) {
1179
+ if (typeof existing !== 'string' || existing === '')
1180
+ return;
1181
+ if (next === existing)
1182
+ return;
1183
+ throw new StackValidationError([
1184
+ {
1185
+ path: field,
1186
+ message: `${field} is immutable once set; register a new ${family} record instead`,
1187
+ },
1188
+ ]);
518
1189
  }
519
1190
  // -------------------------------------------------------
520
- // Grants
1191
+ // Attachments
521
1192
  // -------------------------------------------------------
522
1193
  /**
523
- * Create _grant records that allow entities to call ScopedStack.create()
524
- * for specific record types. Pass null as entityId for a default grant
525
- * that applies to any authenticated entity.
1194
+ * mimeType is a property of the fileId, not the uploader's perspective:
1195
+ * the first metadata record for a fileId fixes it, and a conflicting
1196
+ * later upload is rejected. Cursor-walked so earlier records past page
1197
+ * one are seen. See docs/spec/attachments.md § The `_attachment` record
1198
+ * type.
526
1199
  *
527
- * The _grant@1 type is defined automatically on first use.
1200
+ * Best-effort by construction — check-then-create with no storage-level
1201
+ * uniqueness behind it, so two racing first uploads can both land on a
1202
+ * concurrent server. What survives that race is the *resolution*: which
1203
+ * record establishes the type is firstRecordedAttachment()'s total
1204
+ * order, the same one a server serving Content-Type applies, so both
1205
+ * sides name the same winner however many records exist.
528
1206
  */
529
- async grant(entityId, grants) {
530
- const records = [];
531
- for (const g of grants) {
532
- records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, { typeId: g.typeId, actions: g.actions }, entityId ? { entityId } : {}));
1207
+ async checkAttachmentMimeTypeOnCreate(content) {
1208
+ const { fileId, mimeType } = content;
1209
+ if (typeof fileId !== 'string')
1210
+ return; // schema validation already rejected this
1211
+ const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1212
+ const results = await queryAllPages((q) => this.query(q), {
1213
+ filter: {
1214
+ typeId: metadataTypeId,
1215
+ includeDeleted: true,
1216
+ ...(this.features.contentFieldQuery && { content: { fileId } }),
1217
+ },
1218
+ });
1219
+ const existing = this.features.contentFieldQuery
1220
+ ? results
1221
+ : results.filter((r) => r.content.fileId === fileId);
1222
+ if (existing.length === 0)
1223
+ return;
1224
+ const first = firstRecordedAttachment(existing);
1225
+ const establishedMimeType = first.content.mimeType;
1226
+ if (mimeType !== establishedMimeType) {
1227
+ // Deliberately does not name the established mimeType — that would
1228
+ // confirm a guessed fileId's content type. See the anti-oracle rule
1229
+ // in docs/spec/attachments.md.
1230
+ throw new StackValidationError([
1231
+ {
1232
+ path: 'mimeType',
1233
+ message: 'mimeType conflicts with the mimeType already established for this fileId',
1234
+ },
1235
+ ]);
533
1236
  }
534
- return records;
535
1237
  }
536
- // -------------------------------------------------------
537
- // Private helpers
538
- // -------------------------------------------------------
539
- async seedSystemTypes() {
1238
+ /**
1239
+ * filename is the only mutable field on an _attachment@1 record; fileId,
1240
+ * size, and mimeType are immutable (even a same-value mimeType rewrite
1241
+ * is refused). The correction flow is delete + re-upload. See
1242
+ * docs/spec/attachments.md § The `_attachment` record type.
1243
+ */
1244
+ checkAttachmentImmutableFields(patch, existing, merged) {
1245
+ const errors = [];
1246
+ if (Object.prototype.hasOwnProperty.call(patch, 'mimeType')) {
1247
+ errors.push({
1248
+ path: 'mimeType',
1249
+ message: 'mimeType is immutable after creation; delete and re-upload to change it',
1250
+ });
1251
+ }
1252
+ if (Object.prototype.hasOwnProperty.call(patch, 'fileId') &&
1253
+ merged.fileId !== existing.fileId) {
1254
+ errors.push({ path: 'fileId', message: 'fileId is immutable' });
1255
+ }
1256
+ if (Object.prototype.hasOwnProperty.call(patch, 'size') && merged.size !== existing.size) {
1257
+ errors.push({ path: 'size', message: 'size is immutable' });
1258
+ }
1259
+ if (errors.length > 0) {
1260
+ throw new StackValidationError(errors);
1261
+ }
1262
+ }
1263
+ /**
1264
+ * The same immutability checkAttachmentImmutableFields() enforces, asked
1265
+ * value-wise instead of presence-wise: a migration replaces content
1266
+ * wholesale, so it necessarily re-sends `mimeType`, `fileId` and `size`
1267
+ * (all required) and a presence check would refuse every migration. Only
1268
+ * an actual change is a violation.
1269
+ *
1270
+ * Repointing `fileId` is the one that matters most: an `_attachment@1`
1271
+ * record naming a fileId is what canAccessFile()'s uploader clause reads,
1272
+ * so moving an existing record onto another file's hash is a route to
1273
+ * bytes the record's author never uploaded.
1274
+ */
1275
+ checkAttachmentImmutableOnMigrate(existing, next) {
1276
+ const errors = [];
1277
+ if (next.mimeType !== existing.mimeType) {
1278
+ errors.push({
1279
+ path: 'mimeType',
1280
+ message: 'mimeType is immutable after creation; delete and re-upload to change it',
1281
+ });
1282
+ }
1283
+ if (next.fileId !== existing.fileId) {
1284
+ errors.push({ path: 'fileId', message: 'fileId is immutable' });
1285
+ }
1286
+ if (next.size !== existing.size) {
1287
+ errors.push({ path: 'size', message: 'size is immutable' });
1288
+ }
1289
+ if (errors.length > 0) {
1290
+ throw new StackValidationError(errors);
1291
+ }
1292
+ }
1293
+ /**
1294
+ * `_config.entityId` defines stack ownership; neither update() nor
1295
+ * restoreVersion() may change it. A conflict with stack integrity, not a
1296
+ * schema violation — hence StackConflictError. See docs/spec.md § The
1297
+ * `_config` record.
1298
+ */
1299
+ checkConfigEntityIdUnchanged(existingEntityId, newEntityId) {
1300
+ if (newEntityId !== existingEntityId) {
1301
+ throw new StackConflictError('Cannot change _config.entityId: it defines stack ownership. ' +
1302
+ 'Ownership transfer is not a supported operation.');
1303
+ }
1304
+ }
1305
+ /**
1306
+ * Store bytes and create an _attachment@1 metadata record (owner-
1307
+ * attributed, no entityId), returning that record — `content.fileId`
1308
+ * addresses the bytes, `id` addresses the metadata. Delegates to the
1309
+ * adapter's atomic putAttachmentWithMetadata() when implemented, trusting
1310
+ * the returned record as backend-authoritative; otherwise falls back to
1311
+ * bytes-then-create(). See docs/spec/wire-format.md § Attachments.
1312
+ */
1313
+ async putAttachment(data, mimeType, filename, appId) {
1314
+ this.assertOpen();
1315
+ assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
1316
+ if (this.adapter.putAttachmentWithMetadata) {
1317
+ const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
1318
+ return record;
1319
+ }
1320
+ const fileId = await this.adapter.putAttachment(data);
1321
+ return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
1322
+ fileId,
1323
+ mimeType,
1324
+ size: data.byteLength,
1325
+ ...(filename && { filename }),
1326
+ }, { appId });
1327
+ }
1328
+ async getAttachment(fileId) {
1329
+ this.assertOpen();
1330
+ return this.adapter.getAttachment(fileId);
1331
+ }
1332
+ /**
1333
+ * Delete an attachment's bytes and its _attachment@1 metadata record(s).
1334
+ * Throws StackConflictError if any record in the stack still references the file.
1335
+ * Throws StackNotFoundError if neither metadata records nor bytes exist.
1336
+ */
1337
+ async deleteAttachment(fileId) {
1338
+ this.assertOpen();
1339
+ const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1340
+ const deletedRecordIds = this.adapter.deleteUnreferencedAttachmentRecords
1341
+ ? await this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId)
1342
+ : await this.deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId);
1343
+ if (!deletedRecordIds.length) {
1344
+ try {
1345
+ await this.adapter.getAttachment(fileId);
1346
+ }
1347
+ catch {
1348
+ throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
1349
+ }
1350
+ }
1351
+ await this.adapter.deleteAttachment(fileId);
1352
+ }
1353
+ /**
1354
+ * Non-atomic fallback for adapters that don't implement
1355
+ * deleteUnreferencedAttachmentRecords(): a concurrent associate() can
1356
+ * race between the reference check below and the deletes it guards.
1357
+ */
1358
+ async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId) {
1359
+ // A soft-deleted record still counts as a reference — it must find its
1360
+ // attachments intact on undelete. See docs/spec/attachments.md
1361
+ // § Deleting attachments.
1362
+ const refResult = await this.query({
1363
+ filter: { attachmentFileId: fileId, includeDeleted: true },
1364
+ limit: 1,
1365
+ });
1366
+ if (refResult.records.length > 0) {
1367
+ throw new StackConflictError('Attachment is still referenced by one or more records');
1368
+ }
1369
+ // Cursor-walk with includeDeleted: metadata past page one, or soft-
1370
+ // deleted, must be cleaned up too — not left pointing at deleted bytes.
1371
+ const metaResults = await queryAllPages((q) => this.query(q), {
1372
+ filter: {
1373
+ typeId: metadataTypeId,
1374
+ includeDeleted: true,
1375
+ ...(this.features.contentFieldQuery && { content: { fileId } }),
1376
+ },
1377
+ });
1378
+ const metaRecords = this.features.contentFieldQuery
1379
+ ? metaResults
1380
+ : metaResults.filter((r) => r.content.fileId === fileId);
1381
+ for (const record of metaRecords) {
1382
+ await this.delete(record.id, { hard: true });
1383
+ }
1384
+ return metaRecords.map((r) => r.id);
1385
+ }
1386
+ /**
1387
+ * Sweep for attachment bytes unreachable from any record — live or
1388
+ * soft-deleted — and delete bytes + metadata. Deletion goes through
1389
+ * deleteAttachment(), so a file re-referenced by sweep time is skipped,
1390
+ * not a failure. See docs/spec/attachments.md § Garbage collection.
1391
+ */
1392
+ async collectAttachmentGarbage(opts = {}) {
1393
+ this.assertOpen();
1394
+ const graceMs = opts.graceMs ?? DEFAULT_GC_GRACE_MS;
1395
+ const dryRun = opts.dryRun ?? false;
1396
+ const now = Date.now();
1397
+ const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1398
+ const metaRecords = await queryAllPages((q) => this.query(q), {
1399
+ filter: { typeId: metadataTypeId, includeDeleted: true },
1400
+ });
1401
+ // Newest metadata record's createdAt per fileId, and its size (constant
1402
+ // across records sharing a fileId, since content-addressing guarantees
1403
+ // identical bytes) — used for the grace check and reclaimedBytes.
1404
+ const metaByFile = new Map();
1405
+ for (const record of metaRecords) {
1406
+ const content = record.content;
1407
+ const createdAt = record.createdAt.getTime();
1408
+ const existing = metaByFile.get(content.fileId);
1409
+ if (!existing || createdAt > existing.newestAt) {
1410
+ metaByFile.set(content.fileId, { newestAt: createdAt, size: content.size });
1411
+ }
1412
+ }
1413
+ // Bare-bytes orphans: blobs with zero metadata records, only
1414
+ // discoverable if the blob adapter implements listFiles().
1415
+ const blobByFile = new Map();
1416
+ if (this.adapter.listFiles) {
1417
+ for (const file of await this.adapter.listFiles()) {
1418
+ blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
1419
+ }
1420
+ }
1421
+ const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
1422
+ const deleted = [];
1423
+ let reclaimedBytes = 0;
1424
+ for (const fileId of candidateFileIds) {
1425
+ const refResult = await this.query({
1426
+ filter: { attachmentFileId: fileId, includeDeleted: true },
1427
+ limit: 1,
1428
+ });
1429
+ if (refResult.records.length > 0)
1430
+ continue;
1431
+ const meta = metaByFile.get(fileId);
1432
+ const blob = blobByFile.get(fileId);
1433
+ const newestAt = meta?.newestAt ?? blob?.modifiedAt;
1434
+ if (newestAt !== undefined && now - newestAt < graceMs)
1435
+ continue;
1436
+ const size = meta?.size ?? blob?.size ?? 0;
1437
+ if (dryRun) {
1438
+ deleted.push(fileId);
1439
+ reclaimedBytes += size;
1440
+ continue;
1441
+ }
1442
+ try {
1443
+ await this.deleteAttachment(fileId);
1444
+ }
1445
+ catch (err) {
1446
+ // Raced with a new reference, or another sweep/call already removed
1447
+ // it — not a sweep failure, just move on to the next candidate.
1448
+ if (err instanceof StackConflictError || err instanceof StackNotFoundError)
1449
+ continue;
1450
+ throw err;
1451
+ }
1452
+ deleted.push(fileId);
1453
+ reclaimedBytes += size;
1454
+ }
1455
+ return { deleted, reclaimedBytes };
1456
+ }
1457
+ // -------------------------------------------------------
1458
+ // Lifecycle
1459
+ // -------------------------------------------------------
1460
+ /**
1461
+ * Flush pending writes to the underlying storage. A no-op for adapters
1462
+ * that commit on every call (SQLite, the API adapter); meaningful for
1463
+ * ones that buffer, and for checkpointing a stack that stays open —
1464
+ * close() covers the teardown case on its own.
1465
+ */
1466
+ async flush() {
1467
+ this.assertOpen();
1468
+ await this.adapter.flush?.();
1469
+ }
1470
+ /**
1471
+ * Flush, then release any resources the adapter holds (connections, file
1472
+ * handles, lock files). A failed flush still releases them before it
1473
+ * propagates: an unwritable stack must not also leak a lock file.
1474
+ * See docs/spec/adapters.md § Lifecycle.
1475
+ */
1476
+ async close() {
1477
+ if (this.closed)
1478
+ return;
1479
+ // Marked closed up front so a failed flush can't leave the stack
1480
+ // half-open and invite a second close() onto an already-closed adapter.
1481
+ // Flushes through the adapter directly, past the now-tripped guard.
1482
+ this.closed = true;
1483
+ try {
1484
+ await this.adapter.flush?.();
1485
+ }
1486
+ finally {
1487
+ await this.adapter.close?.();
1488
+ }
1489
+ }
1490
+ /**
1491
+ * Throws once close() has run. Public only so ScopedStack can gate the
1492
+ * one path it takes to the adapter directly; not an app-facing API.
1493
+ */
1494
+ assertOpen() {
1495
+ if (this.closed)
1496
+ throw new StackClosedError();
1497
+ }
1498
+ // -------------------------------------------------------
1499
+ // Grants
1500
+ // -------------------------------------------------------
1501
+ /**
1502
+ * Create _grant records authorizing entities to act on records of
1503
+ * specific types; null entityId writes a default grant (any
1504
+ * authenticated entity).
1505
+ *
1506
+ * Granting an **app** a `-own` action does not contain it the way the
1507
+ * suffix suggests: when that app acts for someone, `-own` is read as the
1508
+ * bare verb and the subject decides which records are in reach, so in a
1509
+ * personal stack — where nearly every record is owner-authored — a
1510
+ * delegated `read-own` is close to `read-any`. Grant an app the types it
1511
+ * needs, not the suffix that looks narrowest. See
1512
+ * docs/spec/access-control.md § Delegation: principal and subject.
1513
+ *
1514
+ * The grantee lives in content.granteeEntityId,
1515
+ * not record.entityId. See docs/spec/access-control.md § Type-level
1516
+ * grants.
1517
+ */
1518
+ async grant(entityId, grants) {
1519
+ this.assertOpen();
1520
+ this.checkGrantsValid(grants);
1521
+ const records = [];
1522
+ for (const g of grants) {
1523
+ records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1524
+ typeId: g.typeId,
1525
+ actions: g.actions,
1526
+ ...(entityId && { granteeEntityId: entityId }),
1527
+ }));
1528
+ }
1529
+ return records;
1530
+ }
1531
+ /**
1532
+ * List _grant records. Omit `entityId` for all grants; pass null for
1533
+ * only default grants; pass a specific entityId for the grants that
1534
+ * currently apply to that entity (ones naming them, plus every default
1535
+ * grant) — the same resolution hasGrant() uses.
1536
+ */
1537
+ async listGrants(entityId) {
1538
+ this.assertOpen();
1539
+ const all = await queryAllPages((q) => this.query(q), {
1540
+ filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
1541
+ });
1542
+ if (entityId === undefined)
1543
+ return all;
1544
+ return all.filter((r) => {
1545
+ const granteeEntityId = r.content.granteeEntityId;
1546
+ return entityId === null
1547
+ ? !granteeEntityId
1548
+ : !granteeEntityId || granteeEntityId === entityId;
1549
+ });
1550
+ }
1551
+ /**
1552
+ * The inverse of grant(): soft-deletes _grant records matching `entityId`
1553
+ * (null for default grants) and each `{ typeId, actions }` pair, at the
1554
+ * same granularity grant() writes. A soft delete like any other — the
1555
+ * owner can undelete a revocation.
1556
+ */
1557
+ async revoke(entityId, grants) {
1558
+ this.assertOpen();
1559
+ const all = await queryAllPages((q) => this.query(q), {
1560
+ filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
1561
+ });
1562
+ for (const g of grants) {
1563
+ const familyId = baseIdOf(g.typeId);
1564
+ const actionSet = new Set(g.actions);
1565
+ const matches = all.filter((r) => {
1566
+ const c = r.content;
1567
+ if (baseIdOf(c.typeId) !== familyId)
1568
+ return false;
1569
+ if ((c.granteeEntityId ?? null) !== entityId)
1570
+ return false;
1571
+ return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
1572
+ });
1573
+ for (const match of matches) {
1574
+ await this.delete(match.id);
1575
+ }
1576
+ }
1577
+ }
1578
+ // -------------------------------------------------------
1579
+ // Private helpers
1580
+ // -------------------------------------------------------
1581
+ /**
1582
+ * Validates the whole grant batch before any record is created, so a bad
1583
+ * entry fails clean rather than leaving a partial set written. Actions
1584
+ * must be known GrantAction values, typeIds must be well-formed, and the
1585
+ * _grant/_config families are refused — see docs/spec/access-control.md
1586
+ * § Type-level grants.
1587
+ */
1588
+ checkGrantsValid(grants) {
1589
+ const errors = [];
1590
+ grants.forEach((g, i) => {
1591
+ g.actions.forEach((action, j) => {
1592
+ if (!GRANT_ACTION_SET.has(action)) {
1593
+ errors.push({
1594
+ path: `grants[${i}].actions[${j}]`,
1595
+ message: `Unknown grant action "${action}"`,
1596
+ });
1597
+ }
1598
+ });
1599
+ if (!isWellFormedTypeId(g.typeId)) {
1600
+ errors.push({
1601
+ path: `grants[${i}].typeId`,
1602
+ message: `"${g.typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
1603
+ });
1604
+ return;
1605
+ }
1606
+ if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(g.typeId))) {
1607
+ const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
1608
+ errors.push({
1609
+ path: `grants[${i}].typeId`,
1610
+ message: `Cannot grant on "${baseIdOf(g.typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
1611
+ });
1612
+ }
1613
+ });
1614
+ if (errors.length > 0) {
1615
+ throw new StackValidationError(errors);
1616
+ }
1617
+ }
1618
+ async seedSystemTypes() {
540
1619
  await this.defineType(`${SYSTEM_TYPES.CONFIG}@1`, 'Config', {
541
1620
  entityId: { kind: 'string', required: true },
542
- timezone: { kind: 'string', required: true },
1621
+ // Optional passthrough app metadata — see ConfigContent.timezone.
1622
+ timezone: { kind: 'string' },
543
1623
  });
544
1624
  await this.defineType(`${SYSTEM_TYPES.ENTITY}@1`, 'Entity', {
1625
+ did: { kind: 'string', required: true },
545
1626
  name: { kind: 'string', required: true },
546
1627
  handle: { kind: 'string' },
547
1628
  });
548
1629
  await this.defineType(`${SYSTEM_TYPES.APP}@1`, 'App', {
1630
+ appId: { kind: 'string', required: true },
549
1631
  name: { kind: 'string', required: true },
550
1632
  version: { kind: 'string' },
1633
+ did: { kind: 'string' },
551
1634
  });
552
1635
  await this.defineType(`${SYSTEM_TYPES.GROUP}@1`, 'Group', {
553
1636
  name: { kind: 'string', required: true },
@@ -557,6 +1640,7 @@ export class Stack {
557
1640
  await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', {
558
1641
  typeId: { kind: 'string', required: true },
559
1642
  actions: { kind: 'array', items: { kind: 'string' }, required: true },
1643
+ granteeEntityId: { kind: 'string' },
560
1644
  });
561
1645
  await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
562
1646
  fileId: { kind: 'string', required: true },
@@ -565,131 +1649,514 @@ export class Stack {
565
1649
  filename: { kind: 'string' },
566
1650
  });
567
1651
  }
568
- async saveVersion(record) {
569
- const version = {
1652
+ /**
1653
+ * Fast-fail for the ifVersion precondition using the already-fetched
1654
+ * record. The adapter re-checks atomically at write time (the source of
1655
+ * truth for concurrent writers) — this just skips validation and
1656
+ * snapshotting work when the mismatch is already visible.
1657
+ */
1658
+ checkIfVersion(existing, ifVersion) {
1659
+ if (ifVersion === undefined || existing.version === ifVersion)
1660
+ return;
1661
+ throw new StackVersionConflictError(`Record "${existing.id}" is at version ${existing.version}, expected ${ifVersion}`, existing.id, ifVersion, existing.version);
1662
+ }
1663
+ /**
1664
+ * Snapshot of a record's prior state, passed with the mutating adapter
1665
+ * call so snapshot and mutation land in one atomic write. `associations`
1666
+ * is always present ([] when empty) so restore can distinguish "cleared"
1667
+ * from a snapshot that omits the key entirely ("leave as-is"). See
1668
+ * docs/spec/versioning.md § Version history.
1669
+ */
1670
+ buildVersionSnapshot(record) {
1671
+ return {
570
1672
  version: record.version,
1673
+ typeId: record.typeId,
571
1674
  content: record.content,
572
1675
  updatedAt: record.updatedAt,
573
1676
  ...(record.entityId && { entityId: record.entityId }),
1677
+ associations: record.associations ?? [],
1678
+ ...(record.permissions && { permissions: record.permissions }),
574
1679
  };
575
- await this.adapter.saveVersion(record.id, version);
576
1680
  }
577
1681
  }
578
1682
  // -------------------------------------------------------
1683
+ // Equality helpers
1684
+ // -------------------------------------------------------
1685
+ /**
1686
+ * Matches the SQLite adapter's association primary key (kind, label,
1687
+ * file_id, related_id).
1688
+ */
1689
+ function associationEqual(a, b) {
1690
+ if (a.kind !== b.kind || a.label !== b.label)
1691
+ return false;
1692
+ if (a.kind === 'attachment' && b.kind === 'attachment')
1693
+ return a.fileId === b.fileId;
1694
+ if (a.kind === 'relationship' && b.kind === 'relationship')
1695
+ return a.recordId === b.recordId;
1696
+ return true;
1697
+ }
1698
+ function permissionEqual(a, b) {
1699
+ if (a.access !== b.access)
1700
+ return false;
1701
+ if (a.access === 'public')
1702
+ return true;
1703
+ if (a.access === 'entity' && b.access === 'entity') {
1704
+ return a.entityId === b.entityId && a.read === b.read && a.write === b.write;
1705
+ }
1706
+ if (a.access === 'group' && b.access === 'group') {
1707
+ return a.groupId === b.groupId && a.role === b.role && a.read === b.read && a.write === b.write;
1708
+ }
1709
+ return false;
1710
+ }
1711
+ function permissionsEqual(a, b) {
1712
+ return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
1713
+ }
1714
+ // -------------------------------------------------------
579
1715
  // ScopedStack
580
1716
  // -------------------------------------------------------
581
1717
  /** Default page size used to fill a permission-filtered query result. */
582
1718
  const DEFAULT_QUERY_LIMIT = 50;
583
1719
  const MAX_QUERY_LIMIT = 1000;
584
1720
  /**
585
- * A permission-enforcing view of a Stack for a single requester. Obtained
586
- * via `stack.asEntity(entityId)` — see there for when to use it.
1721
+ * Ensures `creator` carries an `admin` relationship association, adding one
1722
+ * if it's not already present. Used to bootstrap a `_group` record's first
1723
+ * admin at create time.
1724
+ */
1725
+ function stampGroupAdmin(associations, creator) {
1726
+ const list = associations ?? [];
1727
+ const alreadyAdmin = list.some((a) => a.kind === 'relationship' && a.label === 'admin' && a.recordId === creator);
1728
+ if (alreadyAdmin)
1729
+ return list;
1730
+ return [...list, { kind: 'relationship', label: 'admin', recordId: creator }];
1731
+ }
1732
+ /**
1733
+ * Drops a snapshot's `permissions` — owner-only audit data, never served
1734
+ * to a non-owner history reader. `entityId` (change attribution) stays.
1735
+ * See docs/spec/versioning.md § History access.
1736
+ */
1737
+ function stripVersionPermissions(version) {
1738
+ if (version.permissions === undefined)
1739
+ return version;
1740
+ const { permissions: _permissions, ...rest } = version;
1741
+ return rest;
1742
+ }
1743
+ /**
1744
+ * A permission-enforcing view of a Stack for a single (principal, subject)
1745
+ * pair, obtained
1746
+ * via `stack.asEntity(entityId)`. Missing records throw StackNotFoundError
1747
+ * (or return null on reads); existing-but-inaccessible ones throw
1748
+ * StackPermissionError. See docs/spec/access-control.md.
1749
+ *
1750
+ * Two identities, one rule: **the principal governs authority, the subject
1751
+ * governs attribution.** Grant lookup and the privilege-bearing gates that
1752
+ * no grant reaches (setPermissions, group management, hard delete, widening
1753
+ * access at create time) key on `principalEntityId`; authorship, `-own`
1754
+ * matching, record-level permission resolution, and "files I uploaded"
1755
+ * lookups key on `subjectEntityId`.
587
1756
  *
588
- * Read methods return null for a Record that doesn't exist, and throw
589
- * StackPermissionError for one that exists but isn't readable by the requester.
590
- * Write methods throw StackNotFoundError for a missing Record and
591
- * StackPermissionError for one that exists but isn't writable. This lets
592
- * callers distinguish "not found" from "forbidden".
1757
+ * Unconditional owner access follows that same split rather than one
1758
+ * identity: it answers *what data is reachable* for the subject (an owner
1759
+ * subject resolves past every permission check) and *who may exercise a
1760
+ * privileged verb* for the principal (an owner app is not bounded by
1761
+ * grants). Under delegation both halves apply, and a mistake on the
1762
+ * authority side is an escalation rather than a preference.
593
1763
  */
594
1764
  export class ScopedStack {
595
1765
  stack;
596
- requesterEntityId;
597
- constructor(stack, requesterEntityId) {
1766
+ principalEntityId;
1767
+ subjectEntityId;
1768
+ idTimestampSkewMs;
1769
+ adapter;
1770
+ constructor(stack, principalEntityId, subjectEntityId, idTimestampSkewMs,
1771
+ // Bytes-storage primitive for putAttachment(). Held directly because
1772
+ // ScopedStack always composes bytes + its own create() — the record
1773
+ // must carry the subject's entityId and the principal behind it,
1774
+ // neither of which the adapter-level atomic capability takes.
1775
+ adapter) {
598
1776
  this.stack = stack;
599
- this.requesterEntityId = requesterEntityId;
1777
+ this.principalEntityId = principalEntityId;
1778
+ this.subjectEntityId = subjectEntityId;
1779
+ this.idTimestampSkewMs = idTimestampSkewMs;
1780
+ this.adapter = adapter;
600
1781
  }
601
1782
  get features() {
602
1783
  return this.stack.features;
603
1784
  }
604
- resolveRecord = (id) => this.stack.get(id, { migrate: false });
1785
+ resolveRecord = (id) => this.stack.get(id);
1786
+ /** Whether a delegated app is acting for someone other than itself. */
1787
+ get delegated() {
1788
+ return this.subjectEntityId !== this.principalEntityId;
1789
+ }
1790
+ /**
1791
+ * Whether unconditional owner authority applies — the owner acting as
1792
+ * itself. The verbs that rest on it are irreversible or disclose the
1793
+ * sharing graph, so delegation never carries one to a subject, whichever
1794
+ * side the owner is on. See
1795
+ * docs/spec/access-control.md § Delegation: principal and subject.
1796
+ */
1797
+ get ownerActingAlone() {
1798
+ return !this.delegated && this.principalEntityId === this.stack.ownerEntityId;
1799
+ }
605
1800
  checkRead(record) {
606
- return checkAccess(record, this.requesterEntityId, this.stack.ownerEntityId, 'read', this.resolveRecord);
1801
+ return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'read', this.resolveRecord);
607
1802
  }
608
1803
  checkWrite(record) {
609
- return checkAccess(record, this.requesterEntityId, this.stack.ownerEntityId, 'write', this.resolveRecord);
1804
+ return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'write', this.resolveRecord);
610
1805
  }
611
1806
  /**
612
- * Check whether the requester holds a _grant record covering at least one
613
- * of the given actions for the given type. For -own actions, additionally
614
- * requires that `record.entityId` matches the requester (authorship check).
615
- * Anonymous requesters always return false.
1807
+ * Whether `grantee` holds a _grant covering one of `actions` for the
1808
+ * type's family (grants match by baseId, so a version bump never orphans
1809
+ * one). -own actions additionally require record.entityId === grantee,
1810
+ * unless `matchOwn` is false — on the principal side of a delegated
1811
+ * request the suffix is read as the bare verb, since which records are
1812
+ * reachable is the subject's business. `allowDefault` decides whether a
1813
+ * grant naming nobody counts. Anonymous grantees always return false.
1814
+ *
1815
+ * Reached only through subjectAllows()/principalAllows(), which fix those
1816
+ * two flags per side of the intersection. Call one of those instead.
1817
+ * See docs/spec/access-control.md § Type-level grants.
616
1818
  */
617
- async hasGrant(typeId, actions, record, prefetchedGrants) {
618
- if (!this.requesterEntityId)
1819
+ async hasGrant(typeId, actions, opts) {
1820
+ const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true } = opts;
1821
+ if (!grantee)
1822
+ return false;
1823
+ const familyId = baseIdOf(typeId);
1824
+ // grant() refuses to write these, but a _grant record is an ordinary
1825
+ // Record: an unscoped Stack, an import, or a server mapping a request
1826
+ // body onto Stack can mint one anyway. Refusing at the point of use is
1827
+ // what makes the rule hold regardless of how the record got there.
1828
+ if (UNGRANTABLE_SYSTEM_TYPES.has(familyId))
619
1829
  return false;
620
1830
  let grantRecords;
621
1831
  if (prefetchedGrants !== undefined) {
622
1832
  grantRecords = prefetchedGrants;
623
1833
  }
624
1834
  else {
625
- const result = await this.stack.query({
626
- filter: {
627
- typeId: `${SYSTEM_TYPES.GRANT}@1`,
628
- ...(this.stack.features.contentFieldQuery && { content: { typeId } }),
629
- },
1835
+ // No content-field prefilter: a stored grant's typeId may be a bare
1836
+ // baseId or versioned, so exact matching would wrongly exclude family
1837
+ // versions. Cursor-walked to see grants past page one.
1838
+ grantRecords = await queryAllPages((q) => this.stack.query(q), {
1839
+ filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
630
1840
  });
631
- grantRecords = result.records;
632
1841
  }
633
1842
  return grantRecords.some((r) => {
634
1843
  const c = r.content;
635
- if (c.typeId !== typeId)
1844
+ if (baseIdOf(c.typeId) !== familyId)
1845
+ return false;
1846
+ if (!c.granteeEntityId && !allowDefault)
636
1847
  return false;
637
- if (r.entityId && r.entityId !== this.requesterEntityId)
1848
+ if (c.granteeEntityId && c.granteeEntityId !== grantee)
638
1849
  return false;
639
1850
  return actions.some((action) => {
640
1851
  if (!c.actions.includes(action))
641
1852
  return false;
642
- if (action.endsWith('-own'))
643
- return record?.entityId === this.requesterEntityId;
1853
+ if (matchOwn && action.endsWith('-own'))
1854
+ return record?.entityId === grantee;
644
1855
  return true;
645
1856
  });
646
1857
  });
647
1858
  }
1859
+ /**
1860
+ * The principal half of a delegated request's authority: does the app
1861
+ * hold any grant permitting these verbs on this type at all. Bounds what
1862
+ * the subject's own authority can reach through it, so a powerful app
1863
+ * can never lend its reach to a weaker subject — nor the reverse.
1864
+ * Vacuously true when there's no delegation, where the principal and
1865
+ * subject checks would be the same question asked twice.
1866
+ *
1867
+ * Default grants don't count here. "Any authenticated entity" is about
1868
+ * people who turn up, not software the owner installed — an app reaches
1869
+ * only the types named to it, which is the whole of what containment
1870
+ * promises.
1871
+ */
1872
+ principalAllows(typeId, actions, prefetchedGrants) {
1873
+ if (!this.delegated)
1874
+ return Promise.resolve(true);
1875
+ if (this.principalEntityId === this.stack.ownerEntityId)
1876
+ return Promise.resolve(true);
1877
+ return this.hasGrant(typeId, actions, {
1878
+ grantee: this.principalEntityId,
1879
+ prefetchedGrants,
1880
+ matchOwn: false,
1881
+ allowDefault: false,
1882
+ });
1883
+ }
1884
+ /**
1885
+ * The subject half: which records are reachable, answered with `-own`
1886
+ * matching and default grants both in force — the ordinary reading of a
1887
+ * grant, since the subject is the entity a grant is written about.
1888
+ *
1889
+ * Paired with principalAllows() so that the two halves of the
1890
+ * intersection are the only callers of hasGrant(): its flags differ per
1891
+ * side and mean nothing on their own, so no call site sets them by hand.
1892
+ */
1893
+ subjectAllows(typeId, actions, opts = {}) {
1894
+ return this.hasGrant(typeId, actions, {
1895
+ grantee: this.subjectEntityId,
1896
+ record: opts.record,
1897
+ prefetchedGrants: opts.prefetchedGrants,
1898
+ });
1899
+ }
648
1900
  async canRead(record, prefetchedGrants) {
649
- return ((await this.checkRead(record)) ||
650
- (await this.hasGrant(record.typeId, ['read-own', 'read-any'], record, prefetchedGrants)));
1901
+ const reachable = (await this.checkRead(record)) ||
1902
+ (await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
1903
+ record,
1904
+ prefetchedGrants,
1905
+ }));
1906
+ if (!reachable)
1907
+ return false;
1908
+ return this.principalAllows(record.typeId, ['read-own', 'read-any'], prefetchedGrants);
651
1909
  }
652
1910
  async checkCreateGrant(typeId) {
653
- if (this.requesterEntityId === this.stack.ownerEntityId)
1911
+ if (this.ownerActingAlone)
654
1912
  return true;
655
- return this.hasGrant(typeId, ['create']);
1913
+ const reachable = this.subjectEntityId === this.stack.ownerEntityId ||
1914
+ (await this.subjectAllows(typeId, ['create']));
1915
+ if (!reachable)
1916
+ return false;
1917
+ return this.principalAllows(typeId, ['create']);
1918
+ }
1919
+ /**
1920
+ * `_group` records are managed, not merely written: only the owner or an
1921
+ * `admin` roster holder may mutate them — ordinary write permissions and
1922
+ * grants don't apply. Asked of both identities under delegation, like
1923
+ * setPermissions(). See docs/spec/identity.md § Group.
1924
+ */
1925
+ isGroupManager(record) {
1926
+ if (!this.managesGroup(this.principalEntityId, record))
1927
+ return false;
1928
+ return !this.delegated || this.managesGroup(this.subjectEntityId, record);
656
1929
  }
657
- /** Fetch a record the requester can update (via permissions or an update grant), or throw. */
658
- async requireUpdatable(id) {
659
- const record = await this.stack.get(id, { migrate: false });
1930
+ /** Whether one identity, on its own, manages `record` — see isGroupManager(). */
1931
+ managesGroup(entityId, record) {
1932
+ if (!entityId)
1933
+ return false;
1934
+ if (entityId === this.stack.ownerEntityId)
1935
+ return true;
1936
+ return groupRoleFromAssociations(record.associations, entityId) === 'admin';
1937
+ }
1938
+ /**
1939
+ * Fetch a record the subject can reach and the principal holds `update` on
1940
+ * (via permissions or an update grant), or throw. `mutating: false` marks
1941
+ * the history readers, which borrow this gate without changing anything —
1942
+ * the one way through it a `_grant` Record stays open to.
1943
+ */
1944
+ async requireUpdatable(id, opts = {}) {
1945
+ const record = await this.stack.get(id);
660
1946
  if (!record)
661
1947
  throw new StackNotFoundError(`Record not found: "${id}"`);
662
- const allowed = (await this.checkWrite(record)) ||
663
- (await this.hasGrant(record.typeId, ['update-own', 'update-any'], record));
1948
+ if (opts.mutating ?? true)
1949
+ this.requireOwnerForGrantRecord(record.typeId);
1950
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
1951
+ if (!this.isGroupManager(record))
1952
+ throw new StackPermissionError();
1953
+ return record;
1954
+ }
1955
+ const allowed = ((await this.checkWrite(record)) ||
1956
+ (await this.subjectAllows(record.typeId, ['update-own', 'update-any'], { record }))) &&
1957
+ (await this.principalAllows(record.typeId, ['update-own', 'update-any']));
664
1958
  if (!allowed)
665
1959
  throw new StackPermissionError();
666
1960
  return record;
667
1961
  }
668
- /** Fetch a record the requester can delete (via permissions or a delete grant), or throw. */
1962
+ /**
1963
+ * Fetch a record the subject can reach and the principal holds `delete` on
1964
+ * (via permissions or a delete grant), or throw.
1965
+ */
669
1966
  async requireDeletable(id) {
670
- const record = await this.stack.get(id, { migrate: false });
1967
+ const record = await this.stack.get(id);
671
1968
  if (!record)
672
1969
  throw new StackNotFoundError(`Record not found: "${id}"`);
673
- const allowed = (await this.checkWrite(record)) ||
674
- (await this.hasGrant(record.typeId, ['delete-own', 'delete-any'], record));
1970
+ this.requireOwnerForGrantRecord(record.typeId);
1971
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
1972
+ if (!this.isGroupManager(record))
1973
+ throw new StackPermissionError();
1974
+ return record;
1975
+ }
1976
+ const allowed = ((await this.checkWrite(record)) ||
1977
+ (await this.subjectAllows(record.typeId, ['delete-own', 'delete-any'], { record }))) &&
1978
+ (await this.principalAllows(record.typeId, ['delete-own', 'delete-any']));
675
1979
  if (!allowed)
676
1980
  throw new StackPermissionError();
677
1981
  return record;
678
1982
  }
679
1983
  /**
680
- * Create a new record on behalf of the authenticated requester.
681
- * Requires either an entity-specific _grant or a default _grant for
682
- * the target type. Anonymous requesters (null entityId) are always denied.
683
- * The created record's entityId is always set to the requester.
1984
+ * Whether this request may reference `recordId` (as a parentId or
1985
+ * relationship target). Missing and unreadable both return false —
1986
+ * indistinguishable, so this can't probe for a record's existence.
1987
+ */
1988
+ async canReadReferent(recordId) {
1989
+ const record = await this.stack.get(recordId);
1990
+ if (!record)
1991
+ return false;
1992
+ return this.canRead(record);
1993
+ }
1994
+ /**
1995
+ * Whether this request can read some record referencing `fileId` —
1996
+ * shared by canAccessFile() and the non-owner _attachment@1 create()
1997
+ * carve-out, which deliberately excludes the uploader clause. Walks
1998
+ * every referencing record, short-circuiting on the first readable one.
1999
+ * See docs/spec/attachments.md § Creating `_attachment@1` records directly.
2000
+ */
2001
+ async hasReadableReference(fileId) {
2002
+ const prefetchedGrants = this.subjectEntityId
2003
+ ? await queryAllPages((q) => this.stack.query(q), {
2004
+ filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2005
+ })
2006
+ : undefined;
2007
+ const match = await findFirstMatch((q) => this.stack.query(q), { filter: { attachmentFileId: fileId } }, (record) => this.canRead(record, prefetchedGrants));
2008
+ return match !== undefined;
2009
+ }
2010
+ /**
2011
+ * Whether this request may reference or download `fileId` — the dual of
2012
+ * getAttachment()'s access rule. Nonexistent and inaccessible are
2013
+ * indistinguishable (both false), so no confirmation oracle for guessed
2014
+ * hashes. See docs/spec/access-control.md § Reference-creation gating.
2015
+ */
2016
+ async canAccessFile(fileId) {
2017
+ if (this.ownerActingAlone)
2018
+ return true;
2019
+ // Reaching a file through a record this request can read is already
2020
+ // fully intersected — canRead() applied the principal's mask against
2021
+ // that record's own type, which is the type the reference lives on.
2022
+ if (await this.hasReadableReference(fileId))
2023
+ return true;
2024
+ if (!this.subjectEntityId)
2025
+ return false;
2026
+ // The remaining paths are authorship facts about the subject, so they
2027
+ // decide *which* files match — they are not themselves a grant, and the
2028
+ // principal still needs one of its own on the attachment type.
2029
+ if (!(await this.principalAllows(`${SYSTEM_TYPES.ATTACHMENT}@1`, ['read-own', 'read-any']))) {
2030
+ return false;
2031
+ }
2032
+ if (this.subjectEntityId === this.stack.ownerEntityId)
2033
+ return true;
2034
+ return this.stack.features.contentFieldQuery
2035
+ ? (await this.stack.query({
2036
+ filter: {
2037
+ typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
2038
+ entityId: this.subjectEntityId,
2039
+ content: { fileId },
2040
+ },
2041
+ limit: 1,
2042
+ })).records.length > 0
2043
+ : (await queryAllPages((q) => this.stack.query(q), {
2044
+ filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, entityId: this.subjectEntityId },
2045
+ })).some((r) => r.content.fileId === fileId);
2046
+ }
2047
+ /** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
2048
+ async fileRefFieldNames(typeId) {
2049
+ const type = await this.stack.getType(typeId);
2050
+ if (!type)
2051
+ return [];
2052
+ return Object.entries(type.schema)
2053
+ .filter(([, def]) => def.kind === 'file-ref')
2054
+ .map(([field]) => field);
2055
+ }
2056
+ /**
2057
+ * Gates file-ref content fields on file access, mirroring the
2058
+ * attachment-association gate — a file-ref field conveys attachment
2059
+ * access exactly like an `attachment` association. Only fields present
2060
+ * in `content` are checked (update() is a merge patch; untouched fields
2061
+ * carry no new reference).
2062
+ */
2063
+ async requireFileRefAccess(typeId, content) {
2064
+ for (const field of await this.fileRefFieldNames(typeId)) {
2065
+ const value = content[field];
2066
+ if (typeof value !== 'string')
2067
+ continue;
2068
+ if (!(await this.canAccessFile(value)))
2069
+ throw new StackPermissionError();
2070
+ }
2071
+ }
2072
+ /**
2073
+ * Reference-creation gate for one association: `attachment` requires
2074
+ * file access, `relationship` read access to its target; `tag` is
2075
+ * unchecked, and `_group` roster associations are gated by the stricter
2076
+ * isGroupManager() instead. See docs/spec/access-control.md
2077
+ * § Reference-creation gating.
2078
+ */
2079
+ async requireAssociationAccess(typeId, association) {
2080
+ if (association.kind === 'attachment') {
2081
+ if (!(await this.canAccessFile(association.fileId)))
2082
+ throw new StackPermissionError();
2083
+ }
2084
+ else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
2085
+ if (!(await this.canReadReferent(association.recordId)))
2086
+ throw new StackPermissionError();
2087
+ }
2088
+ }
2089
+ /**
2090
+ * Create a record on behalf of the subject: create grant required,
2091
+ * anonymous denied, entityId set to the subject, client IDs skew-checked,
2092
+ * reference-creating options gated, and non-owner `_attachment@1`
2093
+ * creation refused save one carve-out. A scoped create always stamps
2094
+ * authorship — an absent entityId means an unscoped `Stack` wrote it.
2095
+ * See docs/spec/access-control.md and docs/spec/attachments.md.
684
2096
  */
685
2097
  async create(typeId, content, opts = {}) {
686
- const requester = this.requesterEntityId;
687
- if (!requester)
2098
+ const principal = this.principalEntityId;
2099
+ if (!principal)
688
2100
  throw new StackPermissionError('Anonymous requesters cannot create records');
689
2101
  if (!(await this.checkCreateGrant(typeId))) {
690
2102
  throw new StackPermissionError(`No create grant for type "${typeId}"`);
691
2103
  }
692
- return this.stack.create(typeId, content, { ...opts, entityId: requester });
2104
+ // The exemption is the owner's own, so delegation doesn't carry it: an
2105
+ // owner principal acting for someone else would otherwise let that
2106
+ // subject name any fileId and reach the bytes through the uploader
2107
+ // clause, which matches on the subject this create stamps.
2108
+ if (!this.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
2109
+ const fileId = content.fileId;
2110
+ if (typeof fileId !== 'string' || !(await this.hasReadableReference(fileId))) {
2111
+ throw new StackPermissionError();
2112
+ }
2113
+ }
2114
+ if (opts.permissions?.length && !this.mayGrantAccess()) {
2115
+ throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
2116
+ }
2117
+ this.requireOwnerForOwnerDid(typeId, content.did);
2118
+ await this.requireAppIdMatchesPrincipal(opts.appId);
2119
+ if (opts.id !== undefined) {
2120
+ validateRecordId(opts.id);
2121
+ validateIdTimestampSkew(opts.id, this.idTimestampSkewMs);
2122
+ }
2123
+ if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
2124
+ throw new StackPermissionError();
2125
+ }
2126
+ for (const assoc of opts.associations ?? []) {
2127
+ await this.requireAssociationAccess(typeId, assoc);
2128
+ }
2129
+ await this.requireFileRefAccess(typeId, content);
2130
+ return this.stack.create(typeId, content, {
2131
+ ...opts,
2132
+ entityId: this.subjectEntityId ?? undefined,
2133
+ principalId: this.delegated ? principal : undefined,
2134
+ });
2135
+ }
2136
+ /**
2137
+ * Whether this request may decide who else reaches a record — the rule
2138
+ * setPermissions() enforces, asked at create time too so the reach it
2139
+ * withholds can't be taken one step earlier while authoring. A delegated
2140
+ * app is denied it: widening access is the one thing containment most
2141
+ * needs to hold. Refused rather than silently ignored, so an app never
2142
+ * believes it published something it didn't. Not `ownerActingAlone`:
2143
+ * the record is the subject's own, so an owner principal grants it no
2144
+ * reach the subject lacks.
2145
+ * See docs/spec/access-control.md § Delegation: principal and subject.
2146
+ */
2147
+ mayGrantAccess() {
2148
+ return !this.delegated || this.principalEntityId === this.stack.ownerEntityId;
2149
+ }
2150
+ /**
2151
+ * Whether one identity, on its own, may decide who else reaches `record`
2152
+ * — the owner-or-creator rule setPermissions() enforces, asked of one
2153
+ * side at a time. See
2154
+ * docs/spec/access-control.md § Delegation: principal and subject.
2155
+ */
2156
+ mayReshare(entityId, record) {
2157
+ if (!entityId)
2158
+ return false;
2159
+ return entityId === this.stack.ownerEntityId || entityId === record.entityId;
693
2160
  }
694
2161
  async get(id, opts = {}) {
695
2162
  const record = await this.stack.get(id, opts);
@@ -700,26 +2167,20 @@ export class ScopedStack {
700
2167
  return record;
701
2168
  }
702
2169
  /**
703
- * Query records, filtered to only those the requester can read.
704
- *
705
- * Pagination is filtered-then-refilled: each adapter page is fetched in
706
- * full and entirely evaluated before deciding whether to fetch another,
707
- * so the returned page may overshoot `limit` slightly, but never skips a
708
- * record that shared a page with the one that crossed the threshold (the
709
- * adapter's cursor can't address a position mid-page).
710
- *
711
- * `total` is always null — see the QueryResult.total doc comment.
712
- *
713
- * Grant records are pre-fetched once before the pagination loop so read
714
- * grants don't trigger a separate _grant@1 query per record.
2170
+ * Query records, filtered to those this request can read. Pages are
2171
+ * filtered then refilled, so a page may slightly overshoot `limit` but
2172
+ * never skips a record. `total` is always null (see QueryResult.total).
2173
+ * Grants are prefetched once, cursor-walked to exhaustion.
715
2174
  */
716
2175
  async query(query = {}) {
717
2176
  const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
718
2177
  const records = [];
719
2178
  const maxFetched = limit * 10;
720
2179
  let totalFetched = 0;
721
- const prefetchedGrants = this.requesterEntityId
722
- ? (await this.stack.query({ filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` } })).records
2180
+ const prefetchedGrants = this.principalEntityId
2181
+ ? await queryAllPages((q) => this.stack.query(q), {
2182
+ filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
2183
+ })
723
2184
  : undefined;
724
2185
  let page = { records: [], cursor: query.cursor ?? null, total: null };
725
2186
  do {
@@ -732,115 +2193,306 @@ export class ScopedStack {
732
2193
  } while (records.length < limit && page.cursor && totalFetched < maxFetched);
733
2194
  return { records, cursor: page.cursor, total: null };
734
2195
  }
735
- async update(id, content) {
736
- await this.requireUpdatable(id);
737
- return this.stack.update(id, content);
2196
+ async update(id, content, opts = {}) {
2197
+ const record = await this.requireUpdatable(id);
2198
+ // Value-wise, not presence-wise: a client that reads a card, edits its
2199
+ // `name` and sends the whole content object back is not setting the
2200
+ // binding it round-trips, and `name` is writable by record-level
2201
+ // permission. Same predicate restoreVersion() applies to a snapshot.
2202
+ this.requireOwnerForAppIdentity(record.typeId, (field) => field in content && content[field] !== record.content[field]);
2203
+ // Likewise value-wise: re-sending the DID a card already holds claims
2204
+ // nothing, and immutability refuses changing it regardless.
2205
+ if ('did' in content && content.did !== record.content.did) {
2206
+ this.requireOwnerForOwnerDid(record.typeId, content.did);
2207
+ }
2208
+ await this.requireFileRefAccess(record.typeId, content);
2209
+ return this.stack.update(id, content, opts);
738
2210
  }
739
- async associate(id, association) {
740
- await this.requireUpdatable(id);
741
- return this.stack.associate(id, association);
2211
+ /**
2212
+ * Naming the software behind a key is the trust decision the `_app`
2213
+ * registry exists to record, so both halves of that binding — `did` and
2214
+ * `appId` — belong to the owner alone. Same reasoning that makes `_app`
2215
+ * ungrantable, applied to the fields a lookup reads. Registering a card
2216
+ * is already owner-only; without this, record-level `write` shared on a
2217
+ * card would be a second way in: a card carrying no DID yet could be
2218
+ * pointed at a write-holder's own key while keeping the name the owner
2219
+ * gave it, or relabelled to claim another app's `appId`. `name` and
2220
+ * `version` stay writable — they are display, not lookup.
2221
+ *
2222
+ * Owner *acting alone*, in both directions: a delegated app never holds
2223
+ * it, and an owner principal doesn't lend it to a subject holding
2224
+ * record-level `write` on a card — which would reopen the same route
2225
+ * from the other side.
2226
+ *
2227
+ * `_entity` deliberately does not get this rule: naming people is what a
2228
+ * contacts app does, so its cards stay writable by grant. Uniqueness and
2229
+ * immutability still bind them. See docs/spec/identity.md § DID bindings.
2230
+ */
2231
+ requireOwnerForAppIdentity(typeId, touches) {
2232
+ if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
2233
+ return;
2234
+ if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
2235
+ return;
2236
+ if (!this.ownerActingAlone) {
2237
+ throw new StackPermissionError('Only the stack owner may set an _app record’s did or appId');
2238
+ }
742
2239
  }
743
- async dissociate(id, association) {
2240
+ /**
2241
+ * A self-reported `appId` must agree with the `_app` card naming the
2242
+ * principal's DID, where the owner registered one. `principalId` is
2243
+ * verified, so letting the pair disagree would leave a verified principal
2244
+ * claiming a name the owner gave different software — the cross-check the
2245
+ * registry exists for, refused at the write instead of left to each reader.
2246
+ * A principal with no card keeps `appId` as the bare self-report it is for
2247
+ * every undelegated writer.
2248
+ * See docs/spec/identity.md § Attribution and what can be trusted.
2249
+ */
2250
+ async requireAppIdMatchesPrincipal(appId) {
2251
+ if (appId === undefined || !this.delegated)
2252
+ return;
2253
+ const card = await findFirstMatch((q) => this.stack.query(q), {
2254
+ filter: {
2255
+ baseId: SYSTEM_TYPES.APP,
2256
+ includeDeleted: true,
2257
+ ...(this.stack.features.contentFieldQuery && {
2258
+ content: { did: this.principalEntityId },
2259
+ }),
2260
+ },
2261
+ }, (r) => r.content.did === this.principalEntityId);
2262
+ if (card && card.content.appId !== appId) {
2263
+ throw new StackPermissionError(`appId "${appId}" is not the appId registered for this principal`);
2264
+ }
2265
+ }
2266
+ /**
2267
+ * The owner's own DID is the one `_entity` binding a grantee may not claim.
2268
+ * `ownerProfile` adopts whichever card holds it, so a card minted by
2269
+ * someone else becomes the stack's own profile, and uniqueness then makes
2270
+ * that permanent. Every other DID stays open to a contacts app, which is
2271
+ * the reach `_entity` is grantable for.
2272
+ * See docs/spec/identity.md § DID bindings.
2273
+ */
2274
+ requireOwnerForOwnerDid(typeId, did) {
2275
+ if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
2276
+ return;
2277
+ if (did !== this.stack.ownerEntityId)
2278
+ return;
2279
+ if (!this.ownerActingAlone) {
2280
+ throw new StackPermissionError('Only the stack owner may claim the owner’s own did');
2281
+ }
2282
+ }
2283
+ /**
2284
+ * A `_grant` Record *is* authority, so rewriting one is the escalation
2285
+ * UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
2286
+ * existing grant rather than minting a fresh one. `grant()` and `revoke()`
2287
+ * live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
2288
+ * only: reading a grant Record and its history stays on the ordinary gate.
2289
+ * See docs/spec/access-control.md § Type-level grants.
2290
+ */
2291
+ requireOwnerForGrantRecord(typeId) {
2292
+ if (baseIdOf(typeId) !== SYSTEM_TYPES.GRANT)
2293
+ return;
2294
+ if (!this.ownerActingAlone) {
2295
+ throw new StackPermissionError('Only the stack owner may write a _grant record');
2296
+ }
2297
+ }
2298
+ async associate(id, association, opts = {}) {
2299
+ const record = await this.requireUpdatable(id);
2300
+ await this.requireAssociationAccess(record.typeId, association);
2301
+ return this.stack.associate(id, association, opts);
2302
+ }
2303
+ async dissociate(id, association, opts = {}) {
744
2304
  await this.requireUpdatable(id);
745
- return this.stack.dissociate(id, association);
2305
+ return this.stack.dissociate(id, association, opts);
746
2306
  }
747
- async setPermissions(id, permissions) {
748
- const record = await this.stack.get(id, { migrate: false });
2307
+ async setPermissions(id, permissions, opts = {}) {
2308
+ const record = await this.stack.get(id);
749
2309
  if (!record)
750
2310
  throw new StackNotFoundError(`Record not found: "${id}"`);
751
- const isOwner = this.requesterEntityId === this.stack.ownerEntityId;
752
- const isCreator = this.requesterEntityId === record.entityId;
753
- if (!isOwner && !isCreator)
754
- throw new StackPermissionError();
755
- return this.stack.setPermissions(id, permissions);
2311
+ this.requireOwnerForGrantRecord(record.typeId);
2312
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
2313
+ // Group management, not authorship: a creator later demoted from the
2314
+ // admin roster shouldn't retain a side door to reassign who can read
2315
+ // or write the group record. Same gate as update/associate/delete.
2316
+ if (!this.isGroupManager(record))
2317
+ throw new StackPermissionError();
2318
+ }
2319
+ else {
2320
+ // Intersected like every other authority here: the principal must
2321
+ // hold the verb, and the subject must be able to reach this record —
2322
+ // without which an owner principal would carry its subject to records
2323
+ // the subject cannot touch. create() withholds the same reach via
2324
+ // mayGrantAccess().
2325
+ if (!this.mayReshare(this.principalEntityId, record))
2326
+ throw new StackPermissionError();
2327
+ if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
2328
+ throw new StackPermissionError();
2329
+ }
2330
+ }
2331
+ return this.stack.setPermissions(id, permissions, opts);
756
2332
  }
2333
+ /**
2334
+ * Hard delete is owner-only: it is irreversible and destroys version
2335
+ * history, so neither the write bit nor delete-own/delete-any grants
2336
+ * reach it, and delegation doesn't carry it either. Everyone else is
2337
+ * limited to soft delete.
2338
+ */
757
2339
  async delete(id, opts = {}) {
758
2340
  await this.requireDeletable(id);
2341
+ if (opts.hard && !this.ownerActingAlone) {
2342
+ throw new StackPermissionError('Hard delete is owner-only');
2343
+ }
759
2344
  return this.stack.delete(id, opts);
760
2345
  }
2346
+ /**
2347
+ * Reverse a soft delete. Gated the same as delete() — undelete is the
2348
+ * inverse of soft delete, so granting one direction without the other
2349
+ * would be backwards. Idempotent, per Stack.undelete().
2350
+ */
2351
+ async undelete(id, opts = {}) {
2352
+ await this.requireDeletable(id);
2353
+ return this.stack.undelete(id, opts);
2354
+ }
2355
+ /**
2356
+ * History is the mutation/recovery surface, not a read surface — gated
2357
+ * like update(), with snapshot `permissions` stripped for everyone but
2358
+ * the owner acting alone — a snapshot's permissions are the stack's
2359
+ * sharing graph, which delegation is not a route to. Reading history
2360
+ * changes nothing, so it is the one path the `_grant` write fence leaves
2361
+ * alone: seeing how a Record you can already read got that way is not
2362
+ * the escalation that fence exists to stop, and losing it would leave a
2363
+ * write-holder unable to audit the Record they hold.
2364
+ * See docs/spec/versioning.md § History access.
2365
+ */
761
2366
  async getVersions(id) {
762
- const existing = await this.stack.get(id, { migrate: false });
763
- if (!existing)
764
- throw new StackNotFoundError(`Record not found: "${id}"`);
765
- if (!(await this.canRead(existing)))
766
- throw new StackPermissionError();
767
- return this.stack.getVersions(id);
2367
+ await this.requireUpdatable(id, { mutating: false });
2368
+ const versions = await this.stack.getVersions(id);
2369
+ return this.ownerActingAlone ? versions : versions.map(stripVersionPermissions);
768
2370
  }
2371
+ /** See getVersions() — same mutate-surface gate, same permissions stripping. */
769
2372
  async getVersion(id, version) {
770
- const existing = await this.stack.get(id, { migrate: false });
771
- if (!existing)
772
- throw new StackNotFoundError(`Record not found: "${id}"`);
773
- if (!(await this.canRead(existing)))
774
- throw new StackPermissionError();
775
- return this.stack.getVersion(id, version);
2373
+ await this.requireUpdatable(id, { mutating: false });
2374
+ const target = await this.stack.getVersion(id, version);
2375
+ if (!target)
2376
+ return null;
2377
+ return this.ownerActingAlone ? target : stripVersionPermissions(target);
776
2378
  }
777
- async restoreVersion(id, version) {
778
- await this.requireUpdatable(id);
779
- return this.stack.restoreVersion(id, version);
2379
+ /**
2380
+ * Re-runs the reference-creation checks against the snapshot, so a
2381
+ * restore can't re-convey access to a file or record the subject can no
2382
+ * longer reach today. Only the owner acting alone is exempt: under
2383
+ * delegation the checks resolve against the subject, which is whose reach
2384
+ * the restore would widen. See docs/spec/versioning.md § Restore semantics.
2385
+ */
2386
+ async restoreVersion(id, version, opts = {}) {
2387
+ const record = await this.requireUpdatable(id);
2388
+ if (!this.ownerActingAlone) {
2389
+ const target = await this.stack.getVersion(id, version);
2390
+ if (target) {
2391
+ // A rollback that would move a card's binding is the same trust
2392
+ // decision update() reserves to the owner, reached by another route.
2393
+ this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
2394
+ record.content[field]);
2395
+ await this.requireFileRefAccess(target.typeId, target.content);
2396
+ for (const association of target.associations ?? []) {
2397
+ await this.requireAssociationAccess(target.typeId, association);
2398
+ }
2399
+ }
2400
+ }
2401
+ return this.stack.restoreVersion(id, version, opts);
780
2402
  }
781
2403
  /**
782
- * Store bytes and create an _attachment@1 metadata record owned by the
783
- * requester. Requires a `create` grant on `_attachment@1`.
784
- * Anonymous requesters are always denied.
2404
+ * Commit a per-record migration — **the owner acting alone, only**.
2405
+ *
2406
+ * Migration is owner-driven by design: `migrateAll()`, the bulk path,
2407
+ * lives on `Stack` and is deliberately absent from `StackClient`, the
2408
+ * same way `grant()`/`revoke()` are. This is its per-record counterpart
2409
+ * and carries the same restriction, rather than inventing a grant model
2410
+ * that the bulk path deliberately doesn't have.
2411
+ *
2412
+ * The restriction is what makes the verb safe to expose at all. Migrate
2413
+ * replaces `content` and `typeId` wholesale, so a grant-based version
2414
+ * would have to re-derive every gate `create()` applies at the
2415
+ * destination *and* every gate `update()` applies over the existing
2416
+ * content, and would reopen each one it missed. The sharpest is the
2417
+ * non-owner `_attachment@1` refusal create() carries: without it, a
2418
+ * requester holding a create grant on `_attachment@1` and write access
2419
+ * to any record they authored could migrate that record into the family
2420
+ * naming any `fileId`, then read the bytes through canAccessFile()'s
2421
+ * uploader clause — the exact escalation that carve-out exists to refuse
2422
+ * (see docs/spec/attachments.md § Creating `_attachment@1` records
2423
+ * directly). Ordinary write access to a record is not consent to move it
2424
+ * between families.
2425
+ *
2426
+ * A server implementing `POST /records/:id/migrate` therefore serves it
2427
+ * to the stack owner and answers 403 otherwise. See
2428
+ * docs/spec/data-model.md § Type migrations.
2429
+ */
2430
+ async commitMigration(id, toTypeId, content, opts = {}) {
2431
+ if (!this.ownerActingAlone) {
2432
+ throw new StackPermissionError('Only the stack owner may commit a migration');
2433
+ }
2434
+ return this.stack.commitMigration(id, toTypeId, content, opts);
2435
+ }
2436
+ /**
2437
+ * Store bytes and create an _attachment@1 metadata record (create grant
2438
+ * on `_attachment@1` required; anonymous denied), returning that record.
2439
+ * Authorship and principal are stamped exactly as create() does.
785
2440
  */
786
- async putAttachment(data, mimeType, filename) {
787
- const requester = this.requesterEntityId;
788
- if (!requester) {
2441
+ async putAttachment(data, mimeType, filename, appId) {
2442
+ // The one ScopedStack path that reaches the adapter without going
2443
+ // through Stack first — without this, a closed stack would still write
2444
+ // bytes before the delegated create() refused.
2445
+ this.stack.assertOpen();
2446
+ const principal = this.principalEntityId;
2447
+ if (!principal) {
789
2448
  throw new StackPermissionError('Anonymous requesters cannot upload attachments');
790
2449
  }
791
2450
  if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) {
792
2451
  throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`);
793
2452
  }
794
- const fileId = await this.stack.putAttachmentBytes(data);
795
- await this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
2453
+ await this.requireAppIdMatchesPrincipal(appId);
2454
+ assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
2455
+ const fileId = await this.adapter.putAttachment(data);
2456
+ return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
796
2457
  fileId,
797
2458
  mimeType,
798
2459
  size: data.byteLength,
799
2460
  ...(filename && { filename }),
800
- }, { entityId: requester });
801
- return fileId;
2461
+ }, {
2462
+ entityId: this.subjectEntityId ?? undefined,
2463
+ principalId: this.delegated ? principal : undefined,
2464
+ appId,
2465
+ });
802
2466
  }
803
2467
  /**
804
- * Download attachment bytes. Accessible if the requester is the owner,
805
- * can read any record referencing the file, or uploaded the file themselves
806
- * and it hasn't been associated with a record yet.
2468
+ * Download attachment bytes. Accessible to the owner, a reader of any
2469
+ * referencing record, or the uploader pre-association — the same
2470
+ * predicate as the reference-creation gate (canAccessFile).
807
2471
  */
808
2472
  async getAttachment(fileId) {
809
- if (this.requesterEntityId === this.stack.ownerEntityId) {
810
- return this.stack.getAttachment(fileId);
811
- }
812
- // Accessible if the requester can read any record that references this file
813
- const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
814
- if (refResult.records.length > 0) {
815
- return this.stack.getAttachment(fileId);
816
- }
817
- // Accessible if the requester uploaded it and it hasn't been associated yet
818
- if (this.requesterEntityId) {
819
- const uploadResult = await this.stack.query({
820
- filter: {
821
- typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
822
- entityId: this.requesterEntityId,
823
- ...(this.stack.features.contentFieldQuery && { content: { fileId } }),
824
- },
825
- limit: 1,
826
- });
827
- const hasUpload = this.stack.features.contentFieldQuery
828
- ? uploadResult.records.length > 0
829
- : uploadResult.records.some((r) => r.content.fileId === fileId);
830
- if (hasUpload)
831
- return this.stack.getAttachment(fileId);
832
- }
833
- throw new StackPermissionError();
2473
+ if (!(await this.canAccessFile(fileId)))
2474
+ throw new StackPermissionError();
2475
+ return this.stack.getAttachment(fileId);
834
2476
  }
835
2477
  /**
836
2478
  * Delete an attachment. Only the stack owner may delete attachments.
837
2479
  * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
838
2480
  */
839
2481
  async deleteAttachment(fileId) {
840
- if (this.requesterEntityId !== this.stack.ownerEntityId) {
2482
+ if (!this.ownerActingAlone) {
841
2483
  throw new StackPermissionError('Only the stack owner can delete attachments');
842
2484
  }
843
2485
  return this.stack.deleteAttachment(fileId);
844
2486
  }
2487
+ /**
2488
+ * Sweep for unreferenced attachment bytes and delete them. Only the stack
2489
+ * owner may run this. Delegates to Stack.collectAttachmentGarbage().
2490
+ */
2491
+ async collectAttachmentGarbage(opts) {
2492
+ if (!this.ownerActingAlone) {
2493
+ throw new StackPermissionError('Only the stack owner can collect attachment garbage');
2494
+ }
2495
+ return this.stack.collectAttachmentGarbage(opts);
2496
+ }
845
2497
  }
846
2498
  //# sourceMappingURL=stack.js.map