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