@haverstack/core 0.36.0 → 0.38.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 (76) hide show
  1. package/README.md +19 -15
  2. package/dist/access.d.ts +4 -6
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +7 -8
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter-entry.d.ts +7 -6
  7. package/dist/adapter-entry.d.ts.map +1 -1
  8. package/dist/adapter-entry.js +6 -5
  9. package/dist/adapter-entry.js.map +1 -1
  10. package/dist/attachment-download.d.ts +3 -1
  11. package/dist/attachment-download.d.ts.map +1 -1
  12. package/dist/attachment-download.js +1 -1
  13. package/dist/attachment-download.js.map +1 -1
  14. package/dist/changes.d.ts +22 -16
  15. package/dist/changes.d.ts.map +1 -1
  16. package/dist/changes.js +43 -32
  17. package/dist/changes.js.map +1 -1
  18. package/dist/combine.d.ts +1 -1
  19. package/dist/combine.js +6 -6
  20. package/dist/combine.js.map +1 -1
  21. package/dist/errors.d.ts +24 -12
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +32 -17
  24. package/dist/errors.js.map +1 -1
  25. package/dist/grants.d.ts +15 -24
  26. package/dist/grants.d.ts.map +1 -1
  27. package/dist/grants.js +14 -13
  28. package/dist/grants.js.map +1 -1
  29. package/dist/identity-bindings.js +2 -2
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +12 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/query-validation.d.ts +11 -11
  35. package/dist/query-validation.d.ts.map +1 -1
  36. package/dist/query-validation.js +41 -36
  37. package/dist/query-validation.js.map +1 -1
  38. package/dist/record-changes.d.ts +11 -10
  39. package/dist/record-changes.d.ts.map +1 -1
  40. package/dist/record-changes.js +33 -25
  41. package/dist/record-changes.js.map +1 -1
  42. package/dist/record-id.d.ts +3 -3
  43. package/dist/record-id.js +8 -8
  44. package/dist/record-id.js.map +1 -1
  45. package/dist/scoped-stack.d.ts +78 -33
  46. package/dist/scoped-stack.d.ts.map +1 -1
  47. package/dist/scoped-stack.js +119 -89
  48. package/dist/scoped-stack.js.map +1 -1
  49. package/dist/stack-reads.d.ts +2 -2
  50. package/dist/stack-reads.js +5 -5
  51. package/dist/stack-reads.js.map +1 -1
  52. package/dist/stack.d.ts +162 -154
  53. package/dist/stack.d.ts.map +1 -1
  54. package/dist/stack.js +307 -290
  55. package/dist/stack.js.map +1 -1
  56. package/dist/testing.d.ts +17 -17
  57. package/dist/testing.d.ts.map +1 -1
  58. package/dist/testing.js +46 -42
  59. package/dist/testing.js.map +1 -1
  60. package/dist/types.d.ts +169 -143
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/types.js +2 -2
  63. package/dist/types.js.map +1 -1
  64. package/dist/wire-entry.d.ts +5 -6
  65. package/dist/wire-entry.d.ts.map +1 -1
  66. package/dist/wire-entry.js +5 -11
  67. package/dist/wire-entry.js.map +1 -1
  68. package/dist/wire-record.d.ts +4 -4
  69. package/dist/wire-record.d.ts.map +1 -1
  70. package/dist/wire-record.js +10 -10
  71. package/dist/wire-record.js.map +1 -1
  72. package/dist/wire-request.d.ts +2 -2
  73. package/dist/wire-request.d.ts.map +1 -1
  74. package/dist/wire-request.js +103 -61
  75. package/dist/wire-request.js.map +1 -1
  76. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * - Content validation on write
10
10
  * - Migration registry and explicit, owner-driven migrateAll()
11
11
  * - Version snapshotting on update
12
- * - Soft and hard delete
12
+ * - Soft delete and purge
13
13
  *
14
14
  * Apps should never talk to a StackAdapter directly.
15
15
  */
@@ -21,15 +21,15 @@ import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './a
21
21
  import { compareRecordedAttachments } from './attachment-download.js';
22
22
  import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
23
23
  import { SYSTEM_TYPES } from './types.js';
24
- import { StackClosedError, StackConflictError, StackMigrationError, StackNotFoundError, StackPermissionError, StackQueryError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
- import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
24
+ import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
+ import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
26
26
  import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
27
27
  import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
28
28
  import { assertAttachmentSize, assertContentSize } from './limits.js';
29
29
  import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
30
30
  import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
31
31
  import { associationDelta, associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
32
- import { ScopedStack } from './scoped-stack.js';
32
+ import { ScopedStack, scopeToken } from './scoped-stack.js';
33
33
  // -------------------------------------------------------
34
34
  // Supporting types
35
35
  // -------------------------------------------------------
@@ -46,6 +46,25 @@ const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
46
46
  * § Reparenting.
47
47
  */
48
48
  const MAX_PARENT_DEPTH = 64;
49
+ /**
50
+ * An Actor in the one form `Stack` stores: a `principalId` equal to the
51
+ * subject is dropped, so "acted as itself" has a single spelling, and an
52
+ * empty id is refused rather than stored as a name for nobody.
53
+ * See docs/spec/data-model.md § Actor.
54
+ */
55
+ export function normalizeActor(actor) {
56
+ if (!actor)
57
+ return undefined;
58
+ for (const field of ['subjectId', 'principalId']) {
59
+ if (actor[field] === '') {
60
+ throw new StackBadRequestError(`Invalid ${field}: the empty string is not an id.`);
61
+ }
62
+ }
63
+ const { subjectId, principalId } = actor;
64
+ return principalId === undefined || principalId === subjectId
65
+ ? { subjectId }
66
+ : { subjectId, principalId };
67
+ }
49
68
  /** Sentinel: filter.baseId resolved to zero matching types. */
50
69
  const EMPTY_FAMILY = Symbol('empty-family');
51
70
  /**
@@ -109,27 +128,7 @@ export class Stack {
109
128
  static createActor(record) {
110
129
  if (!record.updatedBy)
111
130
  return undefined;
112
- return {
113
- entityId: record.updatedBy,
114
- ...(record.updatedVia !== undefined && { principalId: record.updatedVia }),
115
- ...(record.appId !== undefined && { appId: record.appId }),
116
- };
117
- }
118
- /**
119
- * The requester behind a write that stamps nothing on the record itself —
120
- * a hard delete, which leaves no record to stamp, and associate()/
121
- * dissociate(), which don't bump and so don't touch updatedBy/updatedVia.
122
- * Hard delete is owner-acting-alone only, so it never has a principal to
123
- * name beside the subject; associate()/dissociate() can run delegated,
124
- * so `updatedVia` rides along here when present.
125
- */
126
- static actorFrom(opts) {
127
- if (!opts.updatedBy)
128
- return undefined;
129
- return {
130
- entityId: opts.updatedBy,
131
- ...(opts.updatedVia !== undefined && { principalId: opts.updatedVia }),
132
- };
131
+ return { ...record.updatedBy, ...(record.appId !== undefined && { appId: record.appId }) };
133
132
  }
134
133
  async getTypeCached(id) {
135
134
  const cached = this.typeCache.get(id);
@@ -141,12 +140,12 @@ export class Stack {
141
140
  return type;
142
141
  }
143
142
  /**
144
- * Create a Stack instance. Reads ownerEntityId and timezone from the adapter.
143
+ * Open a Stack over an adapter. Reads ownerEntityId and timezone from the adapter.
145
144
  */
146
- static async create(adapter, opts = {}) {
145
+ static async open(adapter, opts = {}) {
147
146
  if (!adapter.ownerEntityId) {
148
- throw new Error('Stack misconfiguration: adapter has no ownerEntityId. ' +
149
- 'Initialise the adapter with an entityId before calling Stack.create().');
147
+ throw new InvalidAdapterError('Invalid adapter: adapter has no ownerEntityId. ' +
148
+ 'Initialize the adapter with an ownerEntityId before calling Stack.open().');
150
149
  }
151
150
  const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs);
152
151
  await stack.seedSystemTypes();
@@ -180,7 +179,7 @@ export class Stack {
180
179
  return this.adapter.ownerEntityId;
181
180
  }
182
181
  async getEntityByDid(did) {
183
- return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.features), true);
182
+ return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.capabilities), true);
184
183
  }
185
184
  async getOwnerEntity() {
186
185
  return this.getEntityByDid(this.ownerEntityId);
@@ -188,42 +187,43 @@ export class Stack {
188
187
  get timezone() {
189
188
  return this.adapter.timezone;
190
189
  }
191
- get features() {
190
+ get capabilities() {
192
191
  return this.adapter.capabilities;
193
192
  }
194
193
  /**
195
194
  * Get a permission-scoped view of this Stack, as if requests came from
196
- * the given principal (null = anonymous). Plain Stack methods are
197
- * unscoped; use asEntity() when one Stack serves multiple, possibly
198
- * untrusted, entities.
199
- *
200
- * `onBehalfOf` names the subject a delegated app acts for: authority is
201
- * then the intersection of both parties' grants, while authorship and
202
- * `-own` resolve against the subject. See
195
+ * the given entity acting as itself (null = anonymous). Plain Stack
196
+ * methods are unscoped; use asEntity() when one Stack serves multiple,
197
+ * possibly untrusted, entities. See
203
198
  * docs/spec/access-control.md § Enforcement: Stack.asEntity().
204
199
  */
205
- asEntity(entityId, opts = {}) {
206
- this.assertOpen();
207
- if (opts.onBehalfOf && !entityId) {
208
- throw new StackPermissionError('An anonymous principal cannot act on behalf of an entity');
209
- }
210
- return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter, this.changes);
200
+ asEntity(entityId) {
201
+ return entityId === null ? this.scope(null, null) : this.asActor({ subjectId: entityId });
211
202
  }
212
203
  /**
213
- * Scope to an authenticated session — what StackTokenStore.lookupToken()
214
- * returns. Equivalent to asEntity(principalId, { onBehalfOf: subjectId }),
215
- * and what a server should reach for at its request boundary.
216
- *
217
- * Both identities are DIDs, so passing them positionally leaves nothing
218
- * to catch a swap, and a swapped pair is undetectable in the undelegated
219
- * case where they are equal — it would surface only once delegation is in
220
- * use, as authority no longer fenced by the app's grants and every write
221
- * attributed to the app rather than the person. Taking the pair whole
222
- * removes the order to get wrong. See
223
- * docs/spec/access-control.md § Delegation: principal and subject.
204
+ * Scope to an Actor — a delegated app acting for its user, or a
205
+ * TokenSession exactly as StackTokenStore.lookupToken() returns it, which
206
+ * is what a server should reach for at its request boundary. Authority is
207
+ * the intersection of both parties' grants, while authorship and `-own`
208
+ * resolve against the subject. Taking the pair whole leaves no order to
209
+ * swap. See docs/spec/access-control.md § Delegation: principal and subject.
224
210
  */
225
- forSession(session) {
226
- return this.asEntity(session.principalId, { onBehalfOf: session.subjectId });
211
+ asActor(actor) {
212
+ const { subjectId, principalId = subjectId } = normalizeActor(actor);
213
+ return this.scope(principalId, subjectId);
214
+ }
215
+ scope(principalId, subjectId) {
216
+ this.assertOpen();
217
+ return new ScopedStack(scopeToken, {
218
+ stack: this,
219
+ principalId,
220
+ subjectId,
221
+ idTimestampSkewMs: this.idTimestampSkewMsValue,
222
+ adapter: this.adapter,
223
+ changes: this.changes,
224
+ assertOpen: () => this.assertOpen(),
225
+ relaysChanges: this.relaysChanges,
226
+ });
227
227
  }
228
228
  // -------------------------------------------------------
229
229
  // Types
@@ -235,11 +235,11 @@ export class Stack {
235
235
  * anything beyond additive evolution throws StackSchemaDriftError. See
236
236
  * docs/spec/data-model.md § Schema drift detection.
237
237
  */
238
- async defineType(id, name, schema, opts = {}) {
238
+ async defineType({ id, name, schema, migratesFrom }) {
239
239
  this.assertOpen();
240
240
  const parsed = parseTypeId(id);
241
241
  if (!parsed) {
242
- throw new StackQueryError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
242
+ throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
243
243
  }
244
244
  // Register this version even on the idempotent-no-op path —
245
245
  // presentAtLatest()'s stale-writer detection depends on it.
@@ -284,7 +284,7 @@ export class Stack {
284
284
  schema,
285
285
  schemaHash,
286
286
  createdAt: existing?.createdAt ?? new Date(),
287
- ...(opts.migratesFrom && { migratesFrom: opts.migratesFrom }),
287
+ ...(migratesFrom && { migratesFrom }),
288
288
  };
289
289
  await this.adapter.saveType(type);
290
290
  this.typeCache.set(id, type);
@@ -374,12 +374,12 @@ export class Stack {
374
374
  * too, validates each result before writing, and aborts on the first
375
375
  * validation failure. See docs/spec/data-model.md § Type migrations.
376
376
  */
377
- async migrateAll(baseTypeId) {
377
+ async migrateAll(baseId) {
378
378
  this.assertOpen();
379
379
  const types = await this.adapter.listTypes();
380
- const familyTypeIds = types.filter((t) => t.baseId === baseTypeId).map((t) => t.id);
380
+ const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
381
381
  if (familyTypeIds.length === 0) {
382
- throw new StackMigrationError(`migrateAll: no registered types found for baseTypeId "${baseTypeId}"`);
382
+ throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
383
383
  }
384
384
  let migrated = 0;
385
385
  for (const typeId of familyTypeIds) {
@@ -431,7 +431,7 @@ export class Stack {
431
431
  this.assertOpen();
432
432
  const type = await this.getTypeCached(typeId);
433
433
  if (!type) {
434
- throw new StackQueryError(`Unknown type: "${typeId}". Call defineType() first.`);
434
+ throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
435
435
  }
436
436
  // Copied, never aliased: an import loop that reuses one Date across rows
437
437
  // (`d.setTime(...)` per record) would otherwise retro-edit every record
@@ -468,7 +468,7 @@ export class Stack {
468
468
  if (errors.length > 0) {
469
469
  throw new StackValidationError(errors);
470
470
  }
471
- assertContentSize(content, this.features.limits.contentBytes, 'Content');
471
+ assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
472
472
  if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
473
473
  await this.checkAttachmentMimeTypeOnCreate(content);
474
474
  }
@@ -489,8 +489,9 @@ export class Stack {
489
489
  if (opts.parentId !== undefined)
490
490
  await this.assertNoParentCycle(opts.id, opts.parentId);
491
491
  }
492
+ const createdBy = normalizeActor(opts.createdBy);
492
493
  const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
493
- ? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
494
+ ? stampGroupAdmin(opts.associations, createdBy?.subjectId ?? this.ownerEntityId)
494
495
  : opts.associations;
495
496
  // createdAt drives the id, so the two agree by construction rather than
496
497
  // by coincidence. An explicit one mints via generateIdForTimestamp(),
@@ -504,10 +505,8 @@ export class Stack {
504
505
  // An id field a caller supplies is a value or it is absent — never the
505
506
  // empty string, which names nobody. Refused rather than dropped, so the
506
507
  // caller is never silently ignored.
507
- for (const field of ['entityId', 'appId', 'principalId']) {
508
- if (opts[field] === '') {
509
- throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
510
- }
508
+ if (opts.appId === '') {
509
+ throw new StackBadRequestError('Invalid appId: the empty string is not an id.');
511
510
  }
512
511
  // Every create naming a parent owes the reference check, whether or not
513
512
  // it supplied an id: a destination a caller names has to exist.
@@ -523,14 +522,11 @@ export class Stack {
523
522
  // Presence, not truthiness: '' is refused above, so absence is the
524
523
  // only thing a falsy value could mean here.
525
524
  ...(opts.parentId !== undefined && { parentId: opts.parentId }),
526
- ...(opts.entityId !== undefined && { entityId: opts.entityId }),
527
525
  ...(opts.appId !== undefined && { appId: opts.appId }),
528
- ...(opts.principalId !== undefined && { principalId: opts.principalId }),
529
- // A create's actor is its author, so these are derived rather than
530
- // taken: stamping them here keeps "absent means an unscoped write"
526
+ // A create's actor is its author, so `updatedBy` is derived rather
527
+ // than taken: stamping it here keeps "absent means an unscoped write"
531
528
  // true of version 1 as it is of every later version.
532
- ...(opts.entityId && { updatedBy: opts.entityId }),
533
- ...(opts.principalId && { updatedVia: opts.principalId }),
529
+ ...(createdBy && { createdBy, updatedBy: createdBy }),
534
530
  ...(opts.permissions?.length && { permissions: opts.permissions }),
535
531
  ...(associations?.length && { associations }),
536
532
  ...(opts.unlisted && { unlistedAt: createdAt }),
@@ -614,11 +610,11 @@ export class Stack {
614
610
  // A change set naming only aspects the journal already keeps in full
615
611
  // doesn't bump — the same rule associate()/dissociate() follow
616
612
  // unconditionally. Its actor travels as an explicit opt rather than a
617
- // record stamp, since a non-bumping write never touches
618
- // updatedBy/updatedVia. See docs/spec/versioning.md § Version history.
613
+ // record stamp, since a non-bumping write never touches `updatedBy`.
614
+ // See docs/spec/versioning.md § Version history.
619
615
  const bumps = bumpsVersion(ops);
620
616
  // Computed against the same before/after changeSetOps compared, so
621
- // whether associate/dissociate/permissions appear in `ops` and what
617
+ // whether associate/dissociate/reshare appear in `ops` and what
622
618
  // the journal lists can never disagree. Both halves of the partition
623
619
  // produce the same tagged edits and travel as one list: the journal's
624
620
  // argument is prior state, and an ACL element's is no different from a
@@ -633,17 +629,12 @@ export class Stack {
633
629
  ];
634
630
  const previousParentId = existing.parentId ?? null;
635
631
  const change = new PendingChange(ops, {
636
- actor: Stack.actorFrom(opts),
632
+ actor: normalizeActor(opts.actor),
637
633
  ...(ops.includes('reparent') && { previousParentId }),
638
634
  ...(assocDelta.length && { associations: assocDelta }),
639
635
  });
640
636
  const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
641
- ...(bumps
642
- ? this.writeOptions(existing, opts)
643
- : {
644
- updatedBy: opts.updatedBy,
645
- updatedVia: opts.updatedVia,
646
- }),
637
+ ...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
647
638
  bumpsVersion: bumps,
648
639
  journal: change.journal,
649
640
  });
@@ -700,10 +691,10 @@ export class Stack {
700
691
  if (contentPatch) {
701
692
  // The patch is what travels, so the patch is what's measured — a
702
693
  // small patch against a large record is not an oversized request.
703
- assertContentSize(contentPatch, this.features.limits.contentBytes, 'Patch');
694
+ assertContentSize(contentPatch, this.capabilities.limits.contentBytes, 'Patch');
704
695
  const type = await this.getTypeCached(existing.typeId);
705
696
  if (!type) {
706
- throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
697
+ throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
707
698
  }
708
699
  merged = applyMergePatch(existing.content, contentPatch);
709
700
  const contentErrors = [
@@ -781,7 +772,7 @@ export class Stack {
781
772
  const replaced = (existing.associations ?? []).find((a) => associationEqual(a, association));
782
773
  await this.checkAttachmentAssociationPointers([association]);
783
774
  const change = new PendingChange('associate', {
784
- actor: Stack.actorFrom(opts),
775
+ actor: normalizeActor(opts.actor),
785
776
  // A re-point carries what it overwrote: nothing else retains the
786
777
  // attachmentRecordId that association held.
787
778
  associations: [
@@ -821,7 +812,7 @@ export class Stack {
821
812
  this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
822
813
  }
823
814
  const change = new PendingChange('dissociate', {
824
- actor: Stack.actorFrom(opts),
815
+ actor: normalizeActor(opts.actor),
825
816
  // In full, annotation included — what makes a removal as undoable
826
817
  // from the log as a re-point is. The frame this becomes still names
827
818
  // identity only. See docs/spec/journal.md § The entry.
@@ -849,8 +840,8 @@ export class Stack {
849
840
  if (current.some((p) => associationEqual(p, permission)))
850
841
  return existing;
851
842
  this.assertPermissionSet([...current, permission]);
852
- const change = new PendingChange('permissions', {
853
- actor: Stack.actorFrom(opts),
843
+ const change = new PendingChange('reshare', {
844
+ actor: normalizeActor(opts.actor),
854
845
  associations: [{ op: 'add', association: permission }],
855
846
  });
856
847
  const updated = await this.adapter.associate(id, permission, { journal: change.journal });
@@ -874,8 +865,8 @@ export class Stack {
874
865
  if (!matched)
875
866
  return existing;
876
867
  this.assertPermissionSet(current.filter((p) => !associationEqual(p, permission)));
877
- const change = new PendingChange('permissions', {
878
- actor: Stack.actorFrom(opts),
868
+ const change = new PendingChange('reshare', {
869
+ actor: normalizeActor(opts.actor),
879
870
  associations: [{ op: 'remove', previous: matched }],
880
871
  });
881
872
  const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
@@ -948,7 +939,7 @@ export class Stack {
948
939
  }
949
940
  }
950
941
  /**
951
- * Soft-delete a record (default) or hard-delete it permanently, removing
942
+ * Soft-delete a record (default) or purge it permanently, removing
952
943
  * the record and its version history. Soft delete snapshots and bumps
953
944
  * version; a no-op if already deleted. `_config` is never deletable
954
945
  * (docs/spec.md § The `_config` record). See docs/spec/versioning.md
@@ -959,27 +950,45 @@ export class Stack {
959
950
  * destroys the only rows naming them, so a caller who means to erase the
960
951
  * bytes too has nowhere else to read the argument from afterwards. See
961
952
  * docs/spec/attachments.md § A purge strands the bytes it referenced.
953
+ *
954
+ * deleteAndReturn() is this plus the record itself; reach for that
955
+ * instead when the caller needs the record too — building a response
956
+ * body from a separate read beforehand is exactly the race and the extra
957
+ * round trip it exists to remove.
962
958
  */
963
959
  async delete(id, opts = {}) {
960
+ const { referencedFileIds } = await this.deleteAndReturn(id, opts);
961
+ return { referencedFileIds };
962
+ }
963
+ /**
964
+ * delete(), reporting the record it acted on alongside the files it
965
+ * stranded: as it stood immediately before destruction for a
966
+ * purge, or as the resulting tombstone for a soft delete. Both are
967
+ * captured inside the same write that destroys or tombstones the record,
968
+ * never a read beforehand — a read-then-delete would leave a window
969
+ * where a concurrent write can land, get destroyed or overwritten by
970
+ * this call, and never appear in the record this returns.
971
+ */
972
+ async deleteAndReturn(id, opts = {}) {
964
973
  this.assertOpen();
965
974
  if (id === SYSTEM_TYPES.CONFIG) {
966
975
  throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
967
976
  }
968
- if (opts.hard) {
977
+ if (opts.purge) {
969
978
  // The adapter hands back what it destroyed, captured inside the same
970
979
  // write: a read here instead would race the delete, and afterwards
971
980
  // there is nothing left to read. Null means there was no record, so
972
981
  // nothing was purged and nothing is announced.
973
- const change = new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) });
982
+ const change = new PendingChange('purge', { actor: normalizeActor(opts.actor) });
974
983
  const purged = await this.adapter.deleteRecord(id, {
975
- hard: true,
976
- expectedVersion: opts.ifVersion,
984
+ purge: true,
985
+ ifVersion: opts.ifVersion,
977
986
  journal: change.journal,
978
987
  });
979
988
  if (!purged)
980
- return { referencedFileIds: [] };
989
+ return { record: null, referencedFileIds: [] };
981
990
  this.announce(change, purged);
982
- return { referencedFileIds: await this.referencedFileIds(purged) };
991
+ return { record: purged, referencedFileIds: await this.referencedFileIds(purged) };
983
992
  }
984
993
  const existing = await this.adapter.getRecord(id);
985
994
  if (!existing) {
@@ -989,15 +998,15 @@ export class Stack {
989
998
  // A tombstone's references stand — undelete() must find its
990
999
  // attachments intact — so nothing is stranded and nothing is reported.
991
1000
  if (existing.deletedAt)
992
- return { referencedFileIds: [] };
993
- const change = new PendingChange('delete', { actor: Stack.actorFrom(opts) });
1001
+ return { record: existing, referencedFileIds: [] };
1002
+ const change = new PendingChange('delete', { actor: normalizeActor(opts.actor) });
994
1003
  const deleted = await this.adapter.deleteRecord(id, {
995
1004
  ...this.writeOptions(existing, opts),
996
1005
  journal: change.journal,
997
1006
  });
998
1007
  if (deleted)
999
1008
  this.announce(change, deleted);
1000
- return { referencedFileIds: [] };
1009
+ return { record: deleted ?? existing, referencedFileIds: [] };
1001
1010
  }
1002
1011
  /**
1003
1012
  * The files a record references, by the same definition
@@ -1015,7 +1024,7 @@ export class Stack {
1015
1024
  }
1016
1025
  /**
1017
1026
  * Reverse a soft delete. Idempotent — undeleting a record that isn't
1018
- * deleted returns it unchanged. Hard-deleted records are gone, so this
1027
+ * deleted returns it unchanged. Purged records are gone, so this
1019
1028
  * throws StackNotFoundError for them just like any other missing record.
1020
1029
  * Snapshots and bumps version, same as delete().
1021
1030
  */
@@ -1028,7 +1037,7 @@ export class Stack {
1028
1037
  this.checkIfVersion(existing, opts.ifVersion);
1029
1038
  if (!existing.deletedAt)
1030
1039
  return existing;
1031
- const change = new PendingChange('undelete', { actor: Stack.actorFrom(opts) });
1040
+ const change = new PendingChange('undelete', { actor: normalizeActor(opts.actor) });
1032
1041
  const undeleted = await this.adapter.undeleteRecord(id, {
1033
1042
  ...this.writeOptions(existing, opts),
1034
1043
  journal: change.journal,
@@ -1048,7 +1057,7 @@ export class Stack {
1048
1057
  assertQueryCapabilities(filter, this.adapter.capabilities);
1049
1058
  assertValidSort(query.sort);
1050
1059
  assertSortCapability(query.sort, this.adapter.capabilities);
1051
- assertValidRelatedTo(filter?.relatedTo);
1060
+ assertValidAssociationFilters(filter);
1052
1061
  const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1053
1062
  const resolvedFilter = await this.resolveBaseIdFilter(filter);
1054
1063
  if (resolvedFilter === EMPTY_FAMILY) {
@@ -1127,7 +1136,7 @@ export class Stack {
1127
1136
  }
1128
1137
  const type = await this.getTypeCached(target.typeId);
1129
1138
  if (!type) {
1130
- throw new StackQueryError(`Unknown type: "${target.typeId}"`);
1139
+ throw new StackBadRequestError(`Unknown type: "${target.typeId}"`);
1131
1140
  }
1132
1141
  const errors = [
1133
1142
  ...validateContent(target.content, type.schema),
@@ -1150,7 +1159,10 @@ export class Stack {
1150
1159
  // A restore adds no containment edge and takes none away, so there is
1151
1160
  // no cycle for it to close and nothing for the destination checks to
1152
1161
  // gate. See docs/spec/versioning.md § Restore semantics.
1153
- const change = new PendingChange('restore', { actor: Stack.actorFrom(opts) });
1162
+ const change = new PendingChange('restore', {
1163
+ actor: normalizeActor(opts.actor),
1164
+ previousTypeId: existing.typeId,
1165
+ });
1154
1166
  const restored = await this.adapter.restoreVersion(id, version, {
1155
1167
  ...this.writeOptions(existing, opts),
1156
1168
  journal: change.journal,
@@ -1198,7 +1210,7 @@ export class Stack {
1198
1210
  const id = existing.id;
1199
1211
  const type = await this.getTypeCached(toTypeId);
1200
1212
  if (!type) {
1201
- throw new StackQueryError(`Unknown type: "${toTypeId}". Call defineType() first.`);
1213
+ throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
1202
1214
  }
1203
1215
  const errors = [
1204
1216
  ...validateReservedKeys(content),
@@ -1209,7 +1221,7 @@ export class Stack {
1209
1221
  if (errors.length > 0) {
1210
1222
  throw new StackValidationError(errors);
1211
1223
  }
1212
- assertContentSize(content, this.features.limits.contentBytes, 'Content');
1224
+ assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
1213
1225
  const fromFamily = baseIdOf(existing.typeId);
1214
1226
  const toFamily = baseIdOf(toTypeId);
1215
1227
  const existingContent = existing.content;
@@ -1239,7 +1251,10 @@ export class Stack {
1239
1251
  if (id === SYSTEM_TYPES.CONFIG) {
1240
1252
  this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
1241
1253
  }
1242
- const change = new PendingChange('migrate', { actor: Stack.actorFrom(opts) });
1254
+ const change = new PendingChange('migrate', {
1255
+ actor: normalizeActor(opts.actor),
1256
+ previousTypeId: existing.typeId,
1257
+ });
1243
1258
  const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
1244
1259
  ...this.writeOptions(existing, opts),
1245
1260
  journal: change.journal,
@@ -1299,8 +1314,8 @@ export class Stack {
1299
1314
  }
1300
1315
  }
1301
1316
  /**
1302
- * A unique binding field is what a lookup resolves *by* — a record's
1303
- * `principalId` by `_app.did`, its `entityId` by `_entity.did`. Two cards
1317
+ * A unique binding field is what a lookup resolves *by* — an Actor's
1318
+ * `principalId` by `_app.did`, its `subjectId` by `_entity.did`. Two cards
1304
1319
  * claiming one value would leave that lookup without a single answer, and
1305
1320
  * ambiguity is all an impersonating card needs. Enforced here rather than
1306
1321
  * by schema, since uniqueness is a property of the set, not of the value.
@@ -1318,7 +1333,7 @@ export class Stack {
1318
1333
  baseId: family,
1319
1334
  includeDeleted: true,
1320
1335
  includeUnlisted: true,
1321
- ...(filtersContent(this.features) && { content: { [field]: value } }),
1336
+ ...(filtersContent(this.capabilities) && { content: { [field]: value } }),
1322
1337
  },
1323
1338
  }, (r) => r.id !== excludeId && r.content[field] === value);
1324
1339
  if (clash) {
@@ -1458,19 +1473,20 @@ export class Stack {
1458
1473
  }
1459
1474
  /**
1460
1475
  * Store bytes and create an _attachment@1 metadata record (owner-
1461
- * attributed, no entityId), returning that record — `content.fileId`
1476
+ * attributed, no createdBy), returning that record — `content.fileId`
1462
1477
  * addresses the bytes, `id` addresses the metadata. Delegates to the
1463
1478
  * adapter's atomic putAttachmentWithMetadata() when implemented, trusting
1464
1479
  * the returned record as backend-authoritative; otherwise falls back to
1465
1480
  * bytes-then-create(). See docs/spec/wire-format.md § Attachments.
1466
1481
  */
1467
- async putAttachment(data, mimeType, filename, appId) {
1482
+ async putAttachment(data, opts) {
1483
+ const { mimeType, filename, appId } = opts;
1468
1484
  this.assertOpen();
1469
- assertAttachmentSize(data.byteLength, this.features.limits.attachmentBytes);
1485
+ assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
1470
1486
  if (this.adapter.putAttachmentWithMetadata) {
1471
1487
  // The metadata record is written inside the adapter, so create()
1472
1488
  // never sees it and this is the only place it can be announced.
1473
- const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
1489
+ const record = await this.adapter.putAttachmentWithMetadata(data, opts);
1474
1490
  // The one emission with no journal half of its own: the far side
1475
1491
  // wrote the record, so it appended the entry in that same write —
1476
1492
  // the same division saveVersion() follows over this adapter. See
@@ -1478,7 +1494,7 @@ export class Stack {
1478
1494
  this.announce(new PendingChange('create'), record);
1479
1495
  return record;
1480
1496
  }
1481
- const fileId = await this.adapter.putAttachment(data);
1497
+ const fileId = await this.adapter.putBlob(data);
1482
1498
  return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
1483
1499
  fileId,
1484
1500
  mimeType,
@@ -1488,7 +1504,7 @@ export class Stack {
1488
1504
  }
1489
1505
  async getAttachment(fileId) {
1490
1506
  this.assertOpen();
1491
- return this.adapter.getAttachment(fileId);
1507
+ return this.adapter.getBlob(fileId);
1492
1508
  }
1493
1509
  /**
1494
1510
  * Every `_attachment` record describing `fileId`, earliest-recorded
@@ -1509,7 +1525,7 @@ export class Stack {
1509
1525
  baseId: SYSTEM_TYPES.ATTACHMENT,
1510
1526
  includeDeleted: true,
1511
1527
  includeUnlisted: true,
1512
- ...(filtersContent(this.features) && { content: { fileId } }),
1528
+ ...(filtersContent(this.capabilities) && { content: { fileId } }),
1513
1529
  },
1514
1530
  });
1515
1531
  return results
@@ -1541,7 +1557,7 @@ export class Stack {
1541
1557
  deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
1542
1558
  const at = new Date();
1543
1559
  for (const record of deletedRecords) {
1544
- this.announce(new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) }), record, at);
1560
+ this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
1545
1561
  }
1546
1562
  }
1547
1563
  else {
@@ -1549,13 +1565,13 @@ export class Stack {
1549
1565
  }
1550
1566
  if (!deletedRecords.length) {
1551
1567
  try {
1552
- await this.adapter.getAttachment(fileId);
1568
+ await this.adapter.getBlob(fileId);
1553
1569
  }
1554
1570
  catch {
1555
1571
  throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
1556
1572
  }
1557
1573
  }
1558
- await this.adapter.deleteAttachment(fileId);
1574
+ await this.adapter.deleteBlob(fileId);
1559
1575
  }
1560
1576
  /**
1561
1577
  * Non-atomic fallback for adapters that don't implement
@@ -1567,7 +1583,7 @@ export class Stack {
1567
1583
  // must find its attachments intact on undelete or relisting. See
1568
1584
  // docs/spec/attachments.md § Deleting attachments.
1569
1585
  const refResult = await this.query({
1570
- filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1586
+ filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
1571
1587
  limit: 1,
1572
1588
  });
1573
1589
  if (refResult.records.length > 0) {
@@ -1577,7 +1593,7 @@ export class Stack {
1577
1593
  // too — none of it may be left pointing at deleted bytes.
1578
1594
  const metaRecords = await this.getAttachmentRecords(fileId);
1579
1595
  for (const record of metaRecords) {
1580
- await this.delete(record.id, { hard: true, ...opts });
1596
+ await this.delete(record.id, { purge: true, ...opts });
1581
1597
  }
1582
1598
  return metaRecords;
1583
1599
  }
@@ -1608,19 +1624,19 @@ export class Stack {
1608
1624
  }
1609
1625
  }
1610
1626
  // Bare-bytes orphans: blobs with zero metadata records, only
1611
- // discoverable if the blob adapter implements listFiles().
1627
+ // discoverable if the blob adapter implements listBlobs().
1612
1628
  const blobByFile = new Map();
1613
- if (this.adapter.listFiles) {
1614
- for (const file of await this.adapter.listFiles()) {
1629
+ if (this.adapter.listBlobs) {
1630
+ for (const file of await this.adapter.listBlobs()) {
1615
1631
  blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
1616
1632
  }
1617
1633
  }
1618
1634
  const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
1619
- const deleted = [];
1635
+ const deletedFileIds = [];
1620
1636
  let reclaimedBytes = 0;
1621
1637
  for (const fileId of candidateFileIds) {
1622
1638
  const refResult = await this.query({
1623
- filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1639
+ filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
1624
1640
  limit: 1,
1625
1641
  });
1626
1642
  if (refResult.records.length > 0)
@@ -1632,12 +1648,12 @@ export class Stack {
1632
1648
  continue;
1633
1649
  const size = meta?.size ?? blob?.size ?? 0;
1634
1650
  if (dryRun) {
1635
- deleted.push(fileId);
1651
+ deletedFileIds.push(fileId);
1636
1652
  reclaimedBytes += size;
1637
1653
  continue;
1638
1654
  }
1639
1655
  try {
1640
- await this.deleteAttachment(fileId, { updatedBy: opts.updatedBy });
1656
+ await this.deleteAttachment(fileId, { actor: opts.actor });
1641
1657
  }
1642
1658
  catch (err) {
1643
1659
  // Raced with a new reference, or another sweep/call already removed
@@ -1646,10 +1662,10 @@ export class Stack {
1646
1662
  continue;
1647
1663
  throw err;
1648
1664
  }
1649
- deleted.push(fileId);
1665
+ deletedFileIds.push(fileId);
1650
1666
  reclaimedBytes += size;
1651
1667
  }
1652
- return { deleted, reclaimedBytes };
1668
+ return { deletedFileIds, reclaimedBytes };
1653
1669
  }
1654
1670
  // -------------------------------------------------------
1655
1671
  // Lifecycle
@@ -1690,7 +1706,7 @@ export class Stack {
1690
1706
  *
1691
1707
  * The subscription's own filter goes to the relay rather than being
1692
1708
  * applied on the way back: the emitter at the far end holds the record,
1693
- * so it can answer `entityId` and `parentId`, which the envelope
1709
+ * so it can answer `createdBy` and `parentId`, which the envelope
1694
1710
  * deliberately does not carry. That is also why a relay is opened per
1695
1711
  * subscription rather than shared.
1696
1712
  */
@@ -1711,10 +1727,7 @@ export class Stack {
1711
1727
  void stop();
1712
1728
  };
1713
1729
  }
1714
- /**
1715
- * Whether changes reach this stack from elsewhere. Public only so
1716
- * ScopedStack can refuse a scope it cannot honor; not an app-facing API.
1717
- */
1730
+ /** Whether changes reach this stack from elsewhere. */
1718
1731
  get relaysChanges() {
1719
1732
  return typeof this.adapter.subscribeChanges === 'function';
1720
1733
  }
@@ -1749,23 +1762,20 @@ export class Stack {
1749
1762
  await this.adapter.close?.();
1750
1763
  }
1751
1764
  }
1752
- /**
1753
- * Throws once close() has run. Public only so ScopedStack can gate the
1754
- * one path it takes to the adapter directly; not an app-facing API.
1755
- */
1765
+ /** Throws once close() has run. */
1756
1766
  assertOpen() {
1757
1767
  if (this.closed)
1758
- throw new StackClosedError();
1768
+ throw new UseAfterCloseError();
1759
1769
  }
1760
1770
  // -------------------------------------------------------
1761
1771
  // Grants
1762
1772
  // -------------------------------------------------------
1763
1773
  /**
1764
- * Create _grant records authorizing entities to act on records of
1765
- * specific types. The target is the grantee the record carries:
1766
- * `{ kind: 'entity' }` for one DID, `{ kind: 'group' }` for a `_group`
1767
- * Record's roster at a role, `{ kind: 'authenticated' }` for any
1768
- * authenticated entity.
1774
+ * Create a _grant record authorizing `grantee` to take `actions` on
1775
+ * records of `typeId`: `{ kind: 'entity' }` for one DID, `{ kind: 'group' }`
1776
+ * for a `_group` Record's roster at a role, `{ kind: 'authenticated' }` for
1777
+ * any authenticated entity. The type-level mirror of grantAccess() —
1778
+ * subject first, grantee inside the element.
1769
1779
  *
1770
1780
  * Granting an **app** a `-own` action does not contain it the way the
1771
1781
  * suffix suggests: when that app acts for someone, `-own` is read as the
@@ -1775,22 +1785,19 @@ export class Stack {
1775
1785
  * needs, not the suffix that looks narrowest. See
1776
1786
  * docs/spec/access-control.md § Delegation: principal and subject.
1777
1787
  *
1778
- * The grantee lives in content.grantee, not record.entityId. See
1788
+ * `typeOrBaseId` is a versioned TypeId (`"baseId@version"`) or a bare
1789
+ * baseId; either names the whole family. See
1779
1790
  * docs/spec/access-control.md § Type-level grants.
1780
1791
  */
1781
- async grant(target, grants) {
1792
+ async grantType(typeOrBaseId, grant) {
1782
1793
  this.assertOpen();
1783
- validateGrantTarget(target);
1784
- this.checkGrantsValid(grants);
1785
- const records = [];
1786
- for (const g of grants) {
1787
- records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1788
- typeId: g.typeId,
1789
- actions: g.actions,
1790
- grantee: target,
1791
- }));
1792
- }
1793
- return records;
1794
+ validateGrantTarget(grant.grantee);
1795
+ this.checkGrantValid(typeOrBaseId, grant.actions);
1796
+ return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
1797
+ typeId: typeOrBaseId,
1798
+ actions: grant.actions,
1799
+ grantee: grant.grantee,
1800
+ });
1794
1801
  }
1795
1802
  /**
1796
1803
  * List _grant records. Omit `query` for all grants;
@@ -1801,12 +1808,12 @@ export class Stack {
1801
1808
  * (ones naming them, ones naming a group they belong to at a role they
1802
1809
  * hold, plus every default grant) — the same resolution hasGrant() uses.
1803
1810
  *
1804
- * Every arm but `entity` answers identity — what `grant()` would have
1811
+ * Every arm but `entity` answers identity — what `grantType()` would have
1805
1812
  * written with the same argument. The `entity` arm answers coverage, so
1806
- * its result is not a preview of what `revoke()` would withdraw.
1813
+ * its result is not a preview of what `revokeType()` would withdraw.
1807
1814
  * See docs/spec/access-control.md § Type-level grants.
1808
1815
  */
1809
- async listGrants(query) {
1816
+ async listTypeGrants(query) {
1810
1817
  this.assertOpen();
1811
1818
  if (query !== undefined)
1812
1819
  validateGrantTarget(query, true);
@@ -1835,90 +1842,78 @@ export class Stack {
1835
1842
  return result;
1836
1843
  }
1837
1844
  /**
1838
- * The inverse of grant(): soft-deletes _grant records matching `target`
1839
- * and each `{ typeId, actions }` pair, at the same granularity grant()
1840
- * writes — the grantee is matched whole, role included. A soft delete
1841
- * like any other — the owner can undelete a revocation.
1845
+ * The inverse of grantType(): soft-deletes the _grant records on
1846
+ * `typeOrBaseId`'s family matching `grant`, at the same granularity
1847
+ * grantType() writes — the grantee is matched whole, role included, and
1848
+ * the actions exactly. A soft delete like any other — the owner can
1849
+ * undelete a revocation.
1842
1850
  *
1843
- * Returns the grants it withdrew, as they stood, so a revocation that
1844
- * matched nothing says so rather than passing in silence. An empty result
1845
- * is not an error — a grant already withdrawn or never written is the
1846
- * ordinary case, and re-running a revocation has to stay safe.
1847
- * See docs/spec/access-control.md § Type-level grants.
1851
+ * Returns the grants it withdrew, as they stood: an array, where
1852
+ * revokeAccess() returns one Record, because one target can match several
1853
+ * _grant records. An empty result is not an error — re-running a
1854
+ * revocation has to stay safe. See
1855
+ * docs/spec/access-control.md § Listing and revoking.
1848
1856
  */
1849
- async revoke(target, grants) {
1857
+ async revokeType(typeOrBaseId, grant) {
1850
1858
  this.assertOpen();
1851
- validateGrantTarget(target);
1852
- const revoked = [];
1859
+ validateGrantTarget(grant.grantee);
1860
+ const familyId = baseIdOf(typeOrBaseId);
1861
+ const actionSet = new Set(grant.actions);
1853
1862
  const all = await loadGrantRecords((q) => this.query(q));
1854
- for (const g of grants) {
1855
- const familyId = baseIdOf(g.typeId);
1856
- const actionSet = new Set(g.actions);
1857
- const matches = all.filter((r) => {
1858
- const c = r.content;
1859
- // Establishes the family and that `actions` is a list, so the exact
1860
- // match below reads a real one. Matched against the stored list
1861
- // rather than the reach, so a grant carrying an action this
1862
- // vocabulary drops is not withdrawn by a target that omits it.
1863
- const reach = grantReach(c);
1864
- if (!reach || reach.familyId !== familyId)
1865
- return false;
1866
- if (!matchesGrantTarget(c, target))
1867
- return false;
1868
- return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
1869
- });
1870
- for (const match of matches) {
1871
- await this.delete(match.id);
1872
- revoked.push(match);
1873
- }
1874
- }
1875
- return revoked;
1863
+ const matches = all.filter((r) => {
1864
+ const c = r.content;
1865
+ // Establishes the family and that `actions` is a list, so the exact
1866
+ // match below reads a real one. Matched against the stored list
1867
+ // rather than the reach, so a grant carrying an action this
1868
+ // vocabulary drops is not withdrawn by a target that omits it.
1869
+ const reach = grantReach(c);
1870
+ if (!reach || reach.familyId !== familyId)
1871
+ return false;
1872
+ if (!matchesGrantTarget(c, grant.grantee))
1873
+ return false;
1874
+ return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
1875
+ });
1876
+ for (const match of matches)
1877
+ await this.delete(match.id);
1878
+ return matches;
1876
1879
  }
1877
1880
  // -------------------------------------------------------
1878
1881
  // Private helpers
1879
1882
  // -------------------------------------------------------
1880
1883
  /**
1881
- * Validates the whole grant batch before any record is created, so a bad
1882
- * entry fails clean rather than leaving a partial set written. Actions
1883
- * must be known GrantAction values, typeIds must be well-formed, and the
1884
- * _grant/_config families are refused — see docs/spec/access-control.md
1885
- * § Type-level grants.
1884
+ * Actions must be known GrantAction values, typeIds must be well-formed,
1885
+ * and the _grant/_config families are refused — see
1886
+ * docs/spec/access-control.md § Type-level grants.
1886
1887
  */
1887
- checkGrantsValid(grants) {
1888
+ checkGrantValid(typeId, actions) {
1888
1889
  const errors = [];
1889
- grants.forEach((g, i) => {
1890
- g.actions.forEach((action, j) => {
1891
- if (!GRANT_ACTION_SET.has(action)) {
1892
- errors.push({
1893
- path: `grants[${i}].actions[${j}]`,
1894
- message: `Unknown grant action "${action}"`,
1895
- });
1896
- }
1890
+ actions.forEach((action, j) => {
1891
+ if (!GRANT_ACTION_SET.has(action)) {
1892
+ errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
1893
+ }
1894
+ });
1895
+ if (!isWellFormedTypeId(typeId)) {
1896
+ errors.push({
1897
+ path: 'typeId',
1898
+ message: `"${typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
1897
1899
  });
1898
- if (!isWellFormedTypeId(g.typeId)) {
1899
- errors.push({
1900
- path: `grants[${i}].typeId`,
1901
- message: `"${g.typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
1902
- });
1900
+ }
1901
+ else if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(typeId))) {
1902
+ const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
1903
+ errors.push({
1904
+ path: 'typeId',
1905
+ message: `Cannot grant on "${baseIdOf(typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
1906
+ });
1907
+ }
1908
+ actions.forEach((action, j) => {
1909
+ if (grantConveys(actions, action))
1903
1910
  return;
1904
- }
1905
- if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(g.typeId))) {
1906
- const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
1907
- errors.push({
1908
- path: `grants[${i}].typeId`,
1909
- message: `Cannot grant on "${baseIdOf(g.typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
1910
- });
1911
- }
1912
- g.actions.forEach((action, j) => {
1913
- if (grantConveys(g.actions, action))
1914
- return;
1915
- const companions = READ_COMPANIONS.get(action);
1916
- if (!companions)
1917
- return;
1918
- errors.push({
1919
- path: `grants[${i}].actions[${j}]`,
1920
- message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read`,
1921
- });
1911
+ const companions = READ_COMPANIONS.get(action);
1912
+ if (!companions)
1913
+ return;
1914
+ errors.push({
1915
+ path: `actions[${j}]`,
1916
+ message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read`,
1922
1917
  });
1923
1918
  });
1924
1919
  if (errors.length > 0) {
@@ -1926,58 +1921,82 @@ export class Stack {
1926
1921
  }
1927
1922
  }
1928
1923
  async seedSystemTypes() {
1929
- await this.defineType(`${SYSTEM_TYPES.CONFIG}@1`, 'Config', {
1930
- entityId: { kind: 'string', required: true },
1931
- // Optional passthrough app metadata — see ConfigContent.timezone.
1932
- timezone: { kind: 'string' },
1924
+ await this.defineType({
1925
+ id: `${SYSTEM_TYPES.CONFIG}@1`,
1926
+ name: 'Config',
1927
+ schema: {
1928
+ entityId: { kind: 'string', required: true },
1929
+ // Optional passthrough app metadata — see ConfigContent.timezone.
1930
+ timezone: { kind: 'string' },
1931
+ },
1933
1932
  });
1934
- await this.defineType(`${SYSTEM_TYPES.ENTITY}@1`, 'Entity', {
1935
- did: { kind: 'string', required: true },
1936
- name: { kind: 'string', required: true },
1937
- handle: { kind: 'string' },
1933
+ await this.defineType({
1934
+ id: `${SYSTEM_TYPES.ENTITY}@1`,
1935
+ name: 'Entity',
1936
+ schema: {
1937
+ did: { kind: 'string', required: true },
1938
+ name: { kind: 'string', required: true },
1939
+ handle: { kind: 'string' },
1940
+ },
1938
1941
  });
1939
- await this.defineType(`${SYSTEM_TYPES.APP}@1`, 'App', {
1940
- appId: { kind: 'string', required: true },
1941
- name: { kind: 'string', required: true },
1942
- version: { kind: 'string' },
1943
- did: { kind: 'string' },
1942
+ await this.defineType({
1943
+ id: `${SYSTEM_TYPES.APP}@1`,
1944
+ name: 'App',
1945
+ schema: {
1946
+ appId: { kind: 'string', required: true },
1947
+ name: { kind: 'string', required: true },
1948
+ version: { kind: 'string' },
1949
+ did: { kind: 'string' },
1950
+ },
1944
1951
  });
1945
- await this.defineType(`${SYSTEM_TYPES.GROUP}@1`, 'Group', {
1946
- name: { kind: 'string', required: true },
1947
- handle: { kind: 'string' },
1948
- stackUrl: { kind: 'string' },
1952
+ await this.defineType({
1953
+ id: `${SYSTEM_TYPES.GROUP}@1`,
1954
+ name: 'Group',
1955
+ schema: {
1956
+ name: { kind: 'string', required: true },
1957
+ handle: { kind: 'string' },
1958
+ stackUrl: { kind: 'string' },
1959
+ },
1949
1960
  });
1950
- await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', {
1951
- typeId: { kind: 'string', required: true },
1952
- actions: { kind: 'array', items: { kind: 'string' }, required: true },
1953
- // Required, and closed: a Grant's reach is spelled by its `grantee`,
1954
- // so a record arriving without one is refused here rather than read
1955
- // as a grant to every authenticated entity. The arms differ in which
1956
- // fields they carry, which a schema cannot express — evaluation reads
1957
- // the `kind` and confers nothing on one it does not recognize.
1958
- // See docs/spec/access-control.md § Type-level grants.
1959
- grantee: {
1960
- kind: 'object',
1961
- required: true,
1962
- properties: {
1963
- kind: { kind: 'string', required: true },
1964
- entityId: { kind: 'string' },
1965
- groupId: { kind: 'string' },
1966
- role: { kind: 'string' },
1961
+ await this.defineType({
1962
+ id: `${SYSTEM_TYPES.GRANT}@1`,
1963
+ name: 'Grant',
1964
+ schema: {
1965
+ typeId: { kind: 'string', required: true },
1966
+ actions: { kind: 'array', items: { kind: 'string' }, required: true },
1967
+ // Required, and closed: a Grant's reach is spelled by its `grantee`,
1968
+ // so a record arriving without one is refused here rather than read
1969
+ // as a grant to every authenticated entity. The arms differ in which
1970
+ // fields they carry, which a schema cannot express — evaluation reads
1971
+ // the `kind` and confers nothing on one it does not recognize.
1972
+ // See docs/spec/access-control.md § Type-level grants.
1973
+ grantee: {
1974
+ kind: 'object',
1975
+ required: true,
1976
+ properties: {
1977
+ kind: { kind: 'string', required: true },
1978
+ entityId: { kind: 'string' },
1979
+ groupId: { kind: 'string' },
1980
+ role: { kind: 'string' },
1981
+ },
1967
1982
  },
1968
1983
  },
1969
1984
  });
1970
- await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
1971
- // Deliberately `string`, not `file-ref`: attachmentFileId matching
1972
- // (deleteAttachment()/collectAttachmentGarbage()'s reference scan) is
1973
- // schema-driven, so a `file-ref` fileId here would make every
1974
- // metadata record its own file's reference — nothing would ever be
1975
- // deletable or collectible. See docs/spec/attachments.md § Deleting
1976
- // attachments / Garbage collection.
1977
- fileId: { kind: 'string', required: true },
1978
- mimeType: { kind: 'string', required: true },
1979
- size: { kind: 'number', required: true },
1980
- filename: { kind: 'string' },
1985
+ await this.defineType({
1986
+ id: `${SYSTEM_TYPES.ATTACHMENT}@1`,
1987
+ name: 'Attachment',
1988
+ schema: {
1989
+ // Deliberately `string`, not `file-ref`: referencesFileId matching
1990
+ // (deleteAttachment()/collectAttachmentGarbage()'s reference scan) is
1991
+ // schema-driven, so a `file-ref` fileId here would make every
1992
+ // metadata record its own file's reference — nothing would ever be
1993
+ // deletable or collectible. See docs/spec/attachments.md § Deleting
1994
+ // attachments / Garbage collection.
1995
+ fileId: { kind: 'string', required: true },
1996
+ mimeType: { kind: 'string', required: true },
1997
+ size: { kind: 'number', required: true },
1998
+ filename: { kind: 'string' },
1999
+ },
1981
2000
  });
1982
2001
  }
1983
2002
  /**
@@ -1995,16 +2014,15 @@ export class Stack {
1995
2014
  * The options every version-bumping adapter write carries: the `ifVersion`
1996
2015
  * precondition, the prior-state snapshot that has to land in the same
1997
2016
  * atomic write, and who to attribute the change to. Taken together so a
1998
- * new mutating verb cannot quietly omit one — `updatedVia` most of all,
1999
- * whose absence reads as an undelegated write.
2017
+ * new mutating verb cannot quietly omit one — the actor's `principalId`
2018
+ * most of all, whose absence reads as an undelegated write.
2000
2019
  * See docs/spec/versioning.md § Version history.
2001
2020
  */
2002
2021
  writeOptions(existing, opts) {
2003
2022
  return {
2004
- expectedVersion: opts.ifVersion,
2023
+ ifVersion: opts.ifVersion,
2005
2024
  snapshot: this.buildVersionSnapshot(existing),
2006
- updatedBy: opts.updatedBy,
2007
- updatedVia: opts.updatedVia,
2025
+ actor: normalizeActor(opts.actor),
2008
2026
  };
2009
2027
  }
2010
2028
  /**
@@ -2022,9 +2040,8 @@ export class Stack {
2022
2040
  typeId: record.typeId,
2023
2041
  content: record.content,
2024
2042
  updatedAt: record.updatedAt,
2025
- ...(record.entityId && { entityId: record.entityId }),
2043
+ ...(record.createdBy && { createdBy: record.createdBy }),
2026
2044
  ...(record.updatedBy && { updatedBy: record.updatedBy }),
2027
- ...(record.updatedVia && { updatedVia: record.updatedVia }),
2028
2045
  };
2029
2046
  }
2030
2047
  }