@haverstack/core 0.34.0 → 0.36.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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/access.d.ts +39 -34
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +94 -68
  5. package/dist/access.js.map +1 -1
  6. package/dist/changes.d.ts +22 -33
  7. package/dist/changes.d.ts.map +1 -1
  8. package/dist/changes.js +34 -48
  9. package/dist/changes.js.map +1 -1
  10. package/dist/combine.d.ts.map +1 -1
  11. package/dist/combine.js +7 -0
  12. package/dist/combine.js.map +1 -1
  13. package/dist/grants.d.ts +78 -33
  14. package/dist/grants.d.ts.map +1 -1
  15. package/dist/grants.js +164 -72
  16. package/dist/grants.js.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/query-validation.d.ts +20 -1
  21. package/dist/query-validation.d.ts.map +1 -1
  22. package/dist/query-validation.js +107 -3
  23. package/dist/query-validation.js.map +1 -1
  24. package/dist/record-changes.d.ts +71 -63
  25. package/dist/record-changes.d.ts.map +1 -1
  26. package/dist/record-changes.js +129 -85
  27. package/dist/record-changes.js.map +1 -1
  28. package/dist/scoped-stack.d.ts +118 -116
  29. package/dist/scoped-stack.d.ts.map +1 -1
  30. package/dist/scoped-stack.js +210 -188
  31. package/dist/scoped-stack.js.map +1 -1
  32. package/dist/stack.d.ts +143 -160
  33. package/dist/stack.d.ts.map +1 -1
  34. package/dist/stack.js +271 -225
  35. package/dist/stack.js.map +1 -1
  36. package/dist/testing.d.ts +3 -8
  37. package/dist/testing.d.ts.map +1 -1
  38. package/dist/testing.js +42 -49
  39. package/dist/testing.js.map +1 -1
  40. package/dist/types.d.ts +128 -101
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js.map +1 -1
  43. package/dist/validate.d.ts.map +1 -1
  44. package/dist/validate.js +0 -2
  45. package/dist/validate.js.map +1 -1
  46. package/dist/wire-record.d.ts.map +1 -1
  47. package/dist/wire-record.js.map +1 -1
  48. package/package.json +1 -1
@@ -23,13 +23,13 @@ import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone } from './ac
23
23
  import { ChangeEmitter, Subscription, matchesFilter, passesUnlistedBoundary, assertSinceUsable, } from './changes.js';
24
24
  import { SYSTEM_TYPES } from './types.js';
25
25
  import { StackConflictError, StackError, StackNotFoundError, StackPermissionError, StackRelayScopeError, StackValidationError, } from './errors.js';
26
- import { assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, } from './query-validation.js';
27
- import { grantConveys, grantCoversGrantee, loadGrantRecords, UNGRANTABLE_SYSTEM_TYPES, } from './grants.js';
26
+ import { assertAuthorityAssociations, assertDataAssociations, assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, } from './query-validation.js';
27
+ import { grantConveys, grantCoversGrantee, grantReach, loadGrantRecords, UNGRANTABLE_SYSTEM_TYPES, } from './grants.js';
28
28
  import { bindingFieldsOf } from './identity-bindings.js';
29
29
  import { assertAttachmentSize } from './limits.js';
30
30
  import { validateIdTimestampSkew, validateRecordId } from './record-id.js';
31
31
  import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, findFirstMatch, lookupEntityByDid, queryAllPages, } from './stack-reads.js';
32
- import { associationEqual, isGroupRecord, presentDeleted, stripVersionPermissions, assertNonEmptyChangeSet, } from './record-changes.js';
32
+ import { associationDelta, associationEqual, isGroupRecord, presentDeleted, withoutAuthorityChanges, assertNonEmptyChangeSet, } from './record-changes.js';
33
33
  /**
34
34
  * The authority lookups canRead needs, held for the life of one
35
35
  * subscription. A subscription is long-lived where a query is not, so the
@@ -73,17 +73,11 @@ class FeedAuthorityCache {
73
73
  * lookups it needs cached for the life of the subscription and dropped the
74
74
  * moment anything that feeds them changes.
75
75
  *
76
- * Two properties do the work, and neither is optional:
77
- *
78
- * **Deliveries are serialized.** The permission decision is asynchronous,
79
- * so without a queue two changes to one record could resolve out of order
80
- * and be delivered newest-first — breaking the one ordering guarantee the
81
- * feed makes. Every emission is appended to a chain instead.
82
- *
83
- * **The filter fails closed.** A permission check that throws drops the
84
- * event and reports the error; it never delivers on the assumption that a
85
- * failed check would have passed.
86
- *
76
+ * Two properties do the work, and neither is optional. **Deliveries are
77
+ * serialized**, because the permission decision is asynchronous and two
78
+ * changes to one record could otherwise resolve out of order, breaking the
79
+ * feed's one ordering guarantee. And **the filter fails closed**: a check
80
+ * that throws drops the event and reports the error.
87
81
  * See docs/spec/events.md § Permission scoping.
88
82
  */
89
83
  class ScopedSubscription extends Subscription {
@@ -145,21 +139,16 @@ class ScopedSubscription extends Subscription {
145
139
  * cannot read answers exactly as a missing one does — null on reads,
146
140
  * StackNotFoundError on the verbs that name one — so only a requester who
147
141
  * could have read it is told a refusal was about access.
148
- * See docs/spec/access-control.md § Errors and information exposure.
142
+ * See docs/spec/disclosure.md § Which refusal a Record answers with.
149
143
  *
150
- * Two identities, one rule: **the principal governs authority, the subject
151
- * governs attribution.** Grant lookup and the privilege-bearing gates that
152
- * no grant reaches (resharing, group management, hard delete, widening
153
- * access at create time) key on `principalEntityId`; authorship, `-own`
154
- * matching, record-level permission resolution, and "files I uploaded"
155
- * lookups key on `subjectEntityId`.
156
- *
157
- * Unconditional owner access follows that same split rather than one
158
- * identity: it answers *what data is reachable* for the subject (an owner
159
- * subject resolves past every permission check) and *who may exercise a
160
- * privileged verb* for the principal (an owner app is not bounded by
161
- * grants). Under delegation both halves apply, and a mistake on the
162
- * authority side is an escalation rather than a preference.
144
+ * Which identity each gate keys on follows the module comment's rule.
145
+ * Grant lookup and the privilege-bearing gates no grant reaches
146
+ * (resharing, group management, hard delete, widening access at create
147
+ * time) key on `principalEntityId`; authorship, `-own` matching,
148
+ * record-level permission resolution and "files I uploaded" lookups key on
149
+ * `subjectEntityId`. Unconditional owner access splits the same way: an
150
+ * owner subject resolves past every permission check, an owner principal
151
+ * is not bounded by grants, and under delegation both halves apply.
163
152
  */
164
153
  export class ScopedStack {
165
154
  stack;
@@ -195,6 +184,16 @@ export class ScopedStack {
195
184
  get delegated() {
196
185
  return this.subjectEntityId !== this.principalEntityId;
197
186
  }
187
+ /**
188
+ * Whether any grant could name this request — the one question every
189
+ * prefetch asks before scanning the `_grant` family, since an anonymous
190
+ * requester is reached by none. Both identities are read: `asEntity()`
191
+ * refuses an anonymous principal acting for a subject, so they are null
192
+ * together, and saying so costs nothing.
193
+ */
194
+ get identified() {
195
+ return this.principalEntityId !== null || this.subjectEntityId !== null;
196
+ }
198
197
  /**
199
198
  * Who this request is, for stamping onto whatever it mutates. Attribution
200
199
  * follows record-level authorship: the subject is the actor, and the
@@ -221,11 +220,7 @@ export class ScopedStack {
221
220
  get ownerActingAlone() {
222
221
  return isOwnerActingAlone({ principalId: this.principalEntityId, subjectId: this.subjectEntityId }, this.stack.ownerEntityId);
223
222
  }
224
- /**
225
- * Refuse anything but the owner acting as itself. The verbs resting on
226
- * this tier are irreversible or disclose the sharing graph, so delegation
227
- * never carries one to a subject — see ownerActingAlone.
228
- */
223
+ /** Refuse anything but the owner acting as itself — see ownerActingAlone. */
229
224
  requireOwnerActingAlone(message) {
230
225
  if (!this.ownerActingAlone)
231
226
  throw new StackPermissionError(message);
@@ -241,13 +236,12 @@ export class ScopedStack {
241
236
  * type's family (grants match by baseId, so a version bump never orphans
242
237
  * one). -own actions additionally require record.entityId === grantee,
243
238
  * unless `matchOwn` is false — on the principal side of a delegated
244
- * request the suffix is read as the bare verb, since which records are
239
+ * request the suffix reads as the bare verb, since which records are
245
240
  * reachable is the subject's business. `allowDefault` decides whether a
246
- * grant naming nobody counts. Anonymous grantees always return false.
241
+ * grant naming nobody counts.
247
242
  *
248
243
  * Reached only through subjectAllows()/principalAllows(), which fix those
249
- * two flags per side of the intersection. Call one of those instead.
250
- * See docs/spec/access-control.md § Type-level grants.
244
+ * two flags per side. Call one of those instead.
251
245
  */
252
246
  async hasGrant(typeId, actions, opts) {
253
247
  const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
@@ -276,7 +270,8 @@ export class ScopedStack {
276
270
  }
277
271
  for (const r of grantRecords) {
278
272
  const c = r.content;
279
- if (baseIdOf(c.typeId) !== familyId)
273
+ const reach = grantReach(c);
274
+ if (!reach || reach.familyId !== familyId)
280
275
  continue;
281
276
  const covers = await grantCoversGrantee(c, grantee, {
282
277
  allowDefault,
@@ -287,7 +282,7 @@ export class ScopedStack {
287
282
  if (!covers)
288
283
  continue;
289
284
  const matches = actions.some((action) => {
290
- if (!grantConveys(c.actions, action))
285
+ if (!grantConveys(reach.actions, action))
291
286
  return false;
292
287
  if (matchOwn && action.endsWith('-own'))
293
288
  return record?.entityId === grantee;
@@ -306,15 +301,11 @@ export class ScopedStack {
306
301
  * Vacuously true when there's no delegation, where the principal and
307
302
  * subject checks would be the same question asked twice.
308
303
  *
309
- * Default grants don't count here. "Any authenticated entity" is about
310
- * people who turn up, not software the owner installed — an app reaches
311
- * only the types named to it, which is the whole of what containment
312
- * promises.
313
- *
314
- * Group-targeted grants don't count here either, one step removed: a
315
- * roster is editable by any of the group's admins, so authority reaching
316
- * a principal through one would let someone other than the owner name an
317
- * app to a type. See docs/spec/access-control.md § Type-level grants.
304
+ * Neither default nor group-targeted grants count here. "Any
305
+ * authenticated entity" is about people who turn up, not software the
306
+ * owner installed; and a roster is editable by any of the group's admins,
307
+ * so authority reaching a principal through one would let someone other
308
+ * than the owner name an app to a type.
318
309
  */
319
310
  principalAllows(typeId, actions, prefetchedGrants) {
320
311
  if (!this.delegated)
@@ -352,14 +343,14 @@ export class ScopedStack {
352
343
  * everyone else is told what a missing ID is told, so no one learns an ID
353
344
  * is live who could not have learned it by reading. `message` therefore
354
345
  * only ever reaches someone holding the record already.
355
- * See docs/spec/access-control.md § Errors and information exposure.
346
+ * See docs/spec/disclosure.md § Which refusal a Record answers with.
356
347
  */
357
348
  async denialFor(record, message) {
358
349
  // Prefetched here rather than threaded down from the gate: a write
359
350
  // carried by a record-level permission settles without reading a grant
360
351
  // at all, and that path must not pay for this one. Both halves of
361
352
  // canRead share the one scan.
362
- const grants = this.principalEntityId || this.subjectEntityId ? await this.loadGrants() : undefined;
353
+ const grants = this.identified ? await this.loadGrants() : undefined;
363
354
  if (await this.canRead(record, grants))
364
355
  return new StackPermissionError(message);
365
356
  return new StackNotFoundError(`Record not found: "${record.id}"`);
@@ -470,13 +461,10 @@ export class ScopedStack {
470
461
  * relationship target). Missing and unreadable both return false —
471
462
  * indistinguishable, so this can't probe for a record's existence.
472
463
  *
473
- * The owner acting alone passes without the lookup, which costs nothing:
474
- * there is no record in their own stack they may not read, so the gate
475
- * could only ever refuse them for absence — and absence is `Stack`'s to
476
- * answer, with the conflict that names the problem rather than a
477
- * refusal standing in for it. The anti-oracle property is unchanged for
478
- * every other requester.
479
- * See docs/spec/access-control.md § Reference-creation gating.
464
+ * The owner acting alone passes without the lookup: there is no record in
465
+ * their own stack they may not read, so the gate could only refuse them
466
+ * for absence — which is `Stack`'s to answer, with a conflict that names
467
+ * the problem. See docs/spec/access-control.md § Reference-creation gating.
480
468
  */
481
469
  async canReadReferent(recordId) {
482
470
  if (this.ownerActingAlone)
@@ -489,18 +477,14 @@ export class ScopedStack {
489
477
  /**
490
478
  * Whether this request can read some record referencing `fileId` —
491
479
  * shared by canAccessFile() and the non-owner _attachment@1 create()
492
- * carve-out, which deliberately excludes the uploader clause. Walks
493
- * every referencing record, short-circuiting on the first readable one.
494
- * `_attachment@1` records are excluded from matching outright: the
495
- * carve-out must be satisfied by some *other* record referencing the
496
- * file, never by the requester's own prior metadata record for it —
497
- * allowing that would let one successful guess unlock unlimited further
498
- * metadata records for the same fileId, reintroducing the circularity
499
- * the carve-out's uploader-clause exclusion closes.
480
+ * carve-out, which deliberately excludes the uploader clause.
481
+ * `_attachment@1` records never match: the carve-out has to be satisfied
482
+ * by some *other* record referencing the file, or one successful guess
483
+ * would unlock unlimited further metadata records for the same fileId.
500
484
  * See docs/spec/attachments.md § Creating `_attachment@1` records directly.
501
485
  */
502
486
  async hasReadableReference(fileId) {
503
- const prefetchedGrants = this.subjectEntityId ? await this.loadGrants() : undefined;
487
+ const prefetchedGrants = this.identified ? await this.loadGrants() : undefined;
504
488
  const groupRoles = new Map();
505
489
  const match = await findFirstMatch((q) => this.stack.query(q),
506
490
  // Reach, not enumeration: a record readable by ID conveys the file it
@@ -533,17 +517,25 @@ export class ScopedStack {
533
517
  }
534
518
  if (this.subjectEntityId === this.stack.ownerEntityId)
535
519
  return true;
520
+ // `includeUnlisted`, as hasReadableReference() above: withholding a
521
+ // record from enumeration decides nothing about what it conveys, and
522
+ // the uploader clause does not lapse. See docs/spec/unlisted.md.
536
523
  return filtersContent(this.stack.features)
537
524
  ? (await this.stack.query({
538
525
  filter: {
539
526
  typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
540
527
  entityId: this.subjectEntityId,
541
528
  content: { fileId },
529
+ includeUnlisted: true,
542
530
  },
543
531
  limit: 1,
544
532
  })).records.length > 0
545
533
  : (await queryAllPages((q) => this.stack.query(q), {
546
- filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, entityId: this.subjectEntityId },
534
+ filter: {
535
+ typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
536
+ entityId: this.subjectEntityId,
537
+ includeUnlisted: true,
538
+ },
547
539
  })).some((r) => r.content.fileId === fileId);
548
540
  }
549
541
  /** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
@@ -607,26 +599,19 @@ export class ScopedStack {
607
599
  * creation refused save one carve-out. A scoped create always stamps
608
600
  * authorship — an absent entityId means an unscoped `Stack` wrote it.
609
601
  * `createdAt`/`updatedAt` are refused to everyone but the owner acting
610
- * alone — see the guard below and docs/spec/data-model.md § Record IDs.
611
- * See also docs/spec/access-control.md and docs/spec/attachments.md.
602
+ * alone, rather than dropped, so an app never believes it published
603
+ * something it didn't. See docs/spec/access-control.md and
604
+ * docs/spec/data-model.md § Record IDs.
612
605
  */
613
606
  async create(typeId, content, opts = {}) {
614
607
  const principal = this.principalEntityId;
615
608
  if (!principal)
616
609
  throw new StackPermissionError('Anonymous requesters cannot create records');
617
- // createdAt/updatedAt let a caller backdate a record's clock fields —
618
- // and, without `id` also supplied, its sort position too. Refused to
619
- // everyone but the owner acting alone (undelegated, authenticated as
620
- // themselves): a grantee is exactly the untrusted actor the `id`
621
- // skew check below already exists to stop from forging a sort
622
- // position, and a delegated app acting for the owner inherits none of
623
- // the owner's extra trust — same reasoning as mayGrantAccess() below.
624
- // Refused rather than silently dropped, so an app never believes it
625
- // published something it didn't — asked value-wise, since an
610
+ // A grantee is exactly the untrusted actor the `id` skew check below
611
+ // exists to stop from forging a sort position, and a delegated app
612
+ // acting for the owner inherits none of the owner's extra trust — same
613
+ // reasoning as mayGrantAccess() below. Asked value-wise, since an
626
614
  // `undefined` carries no date and Stack.create() reads it as absent.
627
- // This is also the enforcement a server built on ScopedStack inherits
628
- // for `POST /records`: an owner-authenticated request may carry both
629
- // fields, anyone else's has them ignored.
630
615
  if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) && !this.ownerActingAlone) {
631
616
  throw new StackPermissionError('createdAt/updatedAt can only be set by the stack owner acting alone; a grantee or delegated create always stamps the current time.');
632
617
  }
@@ -705,7 +690,7 @@ export class ScopedStack {
705
690
  * A record this request may read, or null. Never throws
706
691
  * StackPermissionError: an unreadable record answers exactly as a missing
707
692
  * one does, so a caller learns only what it may read.
708
- * See docs/spec/access-control.md § Errors and information exposure.
693
+ * See docs/spec/disclosure.md § Which refusal a Record answers with.
709
694
  */
710
695
  async get(id, opts = {}) {
711
696
  const record = await this.stack.get(id, opts);
@@ -744,7 +729,7 @@ export class ScopedStack {
744
729
  const records = [];
745
730
  const maxFetched = limit * 10;
746
731
  let totalFetched = 0;
747
- const prefetchedGrants = this.principalEntityId ? await this.loadGrants() : undefined;
732
+ const prefetchedGrants = this.identified ? await this.loadGrants() : undefined;
748
733
  // Scoped to this query, like prefetchedGrants beside it: every
749
734
  // candidate Record shares one roster resolution per group, and nothing
750
735
  // is carried into the next operation.
@@ -784,17 +769,48 @@ export class ScopedStack {
784
769
  if (patchErrors.length > 0)
785
770
  throw new StackValidationError(patchErrors);
786
771
  }
787
- const reshares = changes.permissions !== undefined || changes.unlisted !== undefined;
772
+ // Refused here as well as in `Stack` below, so the partition is a
773
+ // refusal rather than a permission question: an `associations` key
774
+ // naming an authority kind is the wrong surface for every requester,
775
+ // owner included.
776
+ if (changes.associations)
777
+ assertDataAssociations(changes.associations, 'associations');
778
+ if (changes.permissions)
779
+ assertAuthorityAssociations(changes.permissions, 'permissions');
788
780
  const writes = changes.contentPatch !== undefined ||
789
781
  changes.associations !== undefined ||
790
782
  changes.parentId !== undefined;
783
+ // `unlisted` reshares on the key's presence alone: it is a boolean, so
784
+ // naming it is the whole of what it can say. `permissions` is a set,
785
+ // and a set restated is not a reshare — see permissionsMove() below,
786
+ // which refines the escalation on this branch alone. A change set
787
+ // naming no writable key is gated before any record is in hand to
788
+ // compute a delta from, so there it reshares on presence, like the
789
+ // boolean. See docs/spec/access-control.md
790
+ // § Storage unifies; the API does not.
791
+ const unlists = changes.unlisted !== undefined;
791
792
  // requireUpdatable() reads the record and applies the write gate; the
792
793
  // reshare keys need the record before their own gate, and a change set
793
794
  // carrying only those must not be held to the write gate it doesn't
794
795
  // need. One read either way.
795
796
  const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
796
- if (writes && reshares)
797
- await this.requireReshareOf(record);
797
+ // Narrowed to what the gate below actually authorized. A key the gate
798
+ // read as inert is dropped rather than forwarded: `Stack` recomputes
799
+ // the delta against its own read of the record, so a key left standing
800
+ // on the strength of one read would write the ACL on the strength of
801
+ // another — and the set that reaches it is the one this requester saw,
802
+ // which is by then stale. See docs/spec/access-control.md
803
+ // § Storage unifies; the API does not.
804
+ let authorized = changes;
805
+ if (writes) {
806
+ const moves = this.permissionsMove(record, changes);
807
+ if (unlists || moves)
808
+ await this.requireReshareOf(record);
809
+ if (changes.permissions !== undefined && !moves && !this.canReshare(record)) {
810
+ const { permissions: _inert, ...rest } = changes;
811
+ authorized = rest;
812
+ }
813
+ }
798
814
  if (changes.contentPatch) {
799
815
  const patch = changes.contentPatch;
800
816
  // Value-wise, not presence-wise: a client that reads a card, edits
@@ -820,7 +836,7 @@ export class ScopedStack {
820
836
  if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
821
837
  throw new StackPermissionError();
822
838
  }
823
- return this.stack.mutate(id, changes, { ...opts, ...this.actor });
839
+ return this.stack.mutate(id, authorized, { ...opts, ...this.actor });
824
840
  }
825
841
  async patchContent(id, patch, opts = {}) {
826
842
  return this.mutate(id, { contentPatch: patch }, opts);
@@ -843,46 +859,54 @@ export class ScopedStack {
843
859
  // decision above, for the reason refuseIfDeleted() gives.
844
860
  return this.refuseIfDeleted(record, true);
845
861
  }
862
+ /**
863
+ * Whether a change set's `permissions` key actually moves the ACL. Read
864
+ * off the computed delta rather than the elements the caller supplied:
865
+ * a gate that inspected only what was named would miss the wholesale
866
+ * replacement that drops everything, which names nothing at all. Any
867
+ * add, remove or repoint in either direction is a reshare.
868
+ * See docs/spec/access-control.md § Record-level permissions.
869
+ */
870
+ permissionsMove(record, changes) {
871
+ if (changes.permissions === undefined)
872
+ return false;
873
+ return associationDelta(record.permissions ?? [], changes.permissions).length > 0;
874
+ }
846
875
  /** The reshare decision alone, for a record already read and write-gated. */
847
876
  async requireReshareOf(record) {
848
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
849
- // Group management, not authorship: a creator later demoted from the
850
- // admin roster shouldn't retain a side door to reassign who can read
851
- // or write the group record.
852
- if (!this.isGroupManager(record))
853
- throw await this.denialFor(record);
854
- return;
855
- }
856
- // Intersected like every other authority here: the principal must hold
857
- // the verb, and the subject must be able to reach this record —
858
- // without which an owner principal would carry its subject to records
859
- // the subject cannot touch. create() withholds the same reach via
860
- // mayGrantAccess().
861
- if (!this.mayReshare(this.principalEntityId, record))
877
+ if (!this.canReshare(record))
862
878
  throw await this.denialFor(record);
863
- if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
864
- throw await this.denialFor(record);
865
- }
879
+ }
880
+ /**
881
+ * Whether this request may decide who else reaches `record` — the
882
+ * decision requireReshareOf() refuses on, without the refusal, for the
883
+ * read paths that project on it rather than throw.
884
+ *
885
+ * A `_group` asks management, not authorship: a creator later demoted
886
+ * from the admin roster shouldn't retain a side door to reassign who can
887
+ * read or write the group record. Everything else is intersected like
888
+ * every other authority here, or an owner principal would carry its
889
+ * subject to records the subject cannot touch.
890
+ */
891
+ canReshare(record) {
892
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP)
893
+ return this.isGroupManager(record);
894
+ if (!this.mayReshare(this.principalEntityId, record))
895
+ return false;
896
+ return !this.delegated || this.mayReshare(this.subjectEntityId, record);
866
897
  }
867
898
  /**
868
899
  * Naming the software behind a key is the trust decision the `_app`
869
900
  * registry exists to record, so both halves of that binding — `did` and
870
- * `appId` — belong to the owner alone. Same reasoning that makes `_app`
871
- * ungrantable, applied to the fields a lookup reads. Registering a card
872
- * is already owner-only; without this, record-level `write` shared on a
873
- * card would be a second way in: a card carrying no DID yet could be
874
- * pointed at a write-holder's own key while keeping the name the owner
875
- * gave it, or relabelled to claim another app's `appId`. `name` and
876
- * `version` stay writable — they are display, not lookup.
877
- *
878
- * Owner *acting alone*, in both directions: a delegated app never holds
879
- * it, and an owner principal doesn't lend it to a subject holding
880
- * record-level `write` on a card — which would reopen the same route
881
- * from the other side.
901
+ * `appId` — belong to the owner alone. Without this, record-level `write`
902
+ * on a card would be a second way in: one carrying no DID yet could be
903
+ * pointed at a write-holder's own key, or relabelled to claim another
904
+ * app's `appId`. `name` and `version` stay writable — display, not lookup.
882
905
  *
883
- * `_entity` deliberately does not get this rule: naming people is what a
884
- * contacts app does, so its cards stay writable by grant. Uniqueness and
885
- * immutability still bind them. See docs/spec/identity.md § DID bindings.
906
+ * Owner *acting alone*, in both directions, or the same route reopens
907
+ * from the subject's side. `_entity` deliberately does not get this rule:
908
+ * naming people is what a contacts app does, so its cards stay writable
909
+ * by grant. See docs/spec/identity.md § DID bindings.
886
910
  */
887
911
  requireOwnerForAppIdentity(typeId, touches) {
888
912
  if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
@@ -893,13 +917,11 @@ export class ScopedStack {
893
917
  }
894
918
  /**
895
919
  * A self-reported `appId` must agree with the `_app` card naming the
896
- * principal's DID, where the owner registered one. `principalId` is
920
+ * principal's DID, where the owner registered one: `principalId` is
897
921
  * verified, so letting the pair disagree would leave a verified principal
898
- * claiming a name the owner gave different software — the cross-check the
899
- * registry exists for, refused at the write instead of left to each reader.
900
- * A principal with no card keeps `appId` as the bare self-report it is for
901
- * every undelegated writer.
902
- * See docs/spec/identity.md § Attribution and what can be trusted.
922
+ * claiming a name the owner gave different software. A principal with no
923
+ * card keeps `appId` as the bare self-report it is for every undelegated
924
+ * writer. See docs/spec/identity.md § Attribution and what can be trusted.
903
925
  */
904
926
  async requireAppIdMatchesPrincipal(appId) {
905
927
  if (appId === undefined || !this.delegated)
@@ -948,15 +970,43 @@ export class ScopedStack {
948
970
  return;
949
971
  throw await this.denialFor(record, 'Only the stack owner may write a _grant record');
950
972
  }
973
+ /**
974
+ * Gated on the write bit alone, which is why the kind refusal comes
975
+ * first: authority reached through this verb would be exactly the
976
+ * escalation the partition exists to stop, decided before any record is
977
+ * read so it cannot depend on who is asking.
978
+ */
951
979
  async associate(id, association) {
980
+ assertDataAssociations([association], 'associate()');
952
981
  const record = await this.requireUpdatable(id);
953
982
  await this.requireAssociationAccess(record.typeId, association);
954
983
  return this.stack.associate(id, association, this.actor);
955
984
  }
985
+ /** See associate() — the same write gate, the same kind refusal. */
956
986
  async dissociate(id, association) {
987
+ assertDataAssociations([association], 'dissociate()');
957
988
  await this.requireUpdatable(id);
958
989
  return this.stack.dissociate(id, association, this.actor);
959
990
  }
991
+ /**
992
+ * Extend who reaches a record — the reshare gate's own verb, on the
993
+ * owner-or-creator rule the `permissions` key carries. The write bit
994
+ * does not confer it: a write-holder who could grant would escalate to
995
+ * deciding who else reaches the record, which is the whole of what
996
+ * scoping access is for.
997
+ * See docs/spec/access-control.md § Record-level permissions.
998
+ */
999
+ async grantAccess(id, permission) {
1000
+ assertAuthorityAssociations([permission], 'grantAccess()');
1001
+ await this.requireReshareable(id);
1002
+ return this.stack.grantAccess(id, permission, this.actor);
1003
+ }
1004
+ /** Withdraw one element of who reaches a record — see grantAccess(). */
1005
+ async revokeAccess(id, permission) {
1006
+ assertAuthorityAssociations([permission], 'revokeAccess()');
1007
+ await this.requireReshareable(id);
1008
+ return this.stack.revokeAccess(id, permission, this.actor);
1009
+ }
960
1010
  /**
961
1011
  * Hard delete is owner-only: it is irreversible and destroys version
962
1012
  * history, so neither the write bit nor delete-own/delete-any grants
@@ -981,46 +1031,40 @@ export class ScopedStack {
981
1031
  }
982
1032
  /**
983
1033
  * History is the mutation/recovery surface, not a read surface — gated
984
- * like patchContent(), with snapshot `permissions` stripped for everyone but
985
- * the owner acting alone — a snapshot's permissions are the stack's
986
- * sharing graph, which delegation is not a route to. Reading history
987
- * changes nothing, so it is the one path the `_grant` write fence leaves
988
- * alone: seeing how a Record you can already read got that way is not
989
- * the escalation that fence exists to stop, and losing it would leave a
990
- * write-holder unable to audit the Record they hold.
991
- * See docs/spec/versioning.md § History access.
1034
+ * like patchContent(). Reading it changes nothing, so it is the one path
1035
+ * the `_grant` write fence leaves alone: seeing how a Record you can
1036
+ * already read got that way is not the escalation that fence exists to
1037
+ * stop. See docs/spec/versioning.md § History access.
992
1038
  */
993
1039
  async getVersions(id) {
994
1040
  await this.requireUpdatable(id, { mutating: false });
995
- const versions = await this.stack.getVersions(id);
996
- return this.ownerActingAlone ? versions : versions.map(stripVersionPermissions);
1041
+ return this.stack.getVersions(id);
997
1042
  }
998
1043
  /**
999
1044
  * See getVersions() — the same mutate-surface gate, for the same reason.
1000
- * A journal entry carries no snapshot `permissions` to strip: it names
1001
- * that a permission set moved, never what it moved to.
1002
- * See docs/spec/journal.md § Reading it.
1045
+ *
1046
+ * The journal is where a Record's sharing history lives, and that half
1047
+ * is the resharer's: a reader who could not have moved the ACL is served
1048
+ * the `permissions` op without the elements beneath it, so the entry
1049
+ * still names that it moved. Asked of both identities, so delegation is
1050
+ * no route to it either. See docs/spec/journal.md § Reading it.
1003
1051
  */
1004
1052
  async getJournal(id, query = {}) {
1005
- await this.requireUpdatable(id, { mutating: false });
1006
- return this.stack.getJournal(id, query);
1053
+ const record = await this.requireUpdatable(id, { mutating: false });
1054
+ const entries = await this.stack.getJournal(id, query);
1055
+ return this.canReshare(record) ? entries : entries.map(withoutAuthorityChanges);
1007
1056
  }
1008
- /** See getVersions() — same mutate-surface gate, same permissions stripping. */
1057
+ /** See getVersions() — the same mutate-surface gate. */
1009
1058
  async getVersion(id, version) {
1010
1059
  await this.requireUpdatable(id, { mutating: false });
1011
- const target = await this.stack.getVersion(id, version);
1012
- if (!target)
1013
- return null;
1014
- return this.ownerActingAlone ? target : stripVersionPermissions(target);
1060
+ return this.stack.getVersion(id, version);
1015
1061
  }
1016
1062
  /**
1017
1063
  * Re-runs the reference-creation checks against the snapshot, so a
1018
- * restore can't re-convey access to a file or record the subject can no
1019
- * longer reach today — the snapshot's `parentId` among them, gated
1020
- * exactly as a change set's `parentId` gates a destination named directly. Only the
1021
- * owner acting alone is exempt: under delegation the checks resolve
1022
- * against the subject, which is whose reach the restore would widen.
1023
- * See docs/spec/versioning.md § Restore semantics.
1064
+ * restore can't re-convey access to a file the subject can no longer
1065
+ * reach today. Only the owner acting alone is exempt: under delegation
1066
+ * the checks resolve against the subject, which is whose reach the
1067
+ * restore would widen. See docs/spec/versioning.md § Restore semantics.
1024
1068
  */
1025
1069
  async restoreVersion(id, version, opts = {}) {
1026
1070
  const record = await this.requireUpdatable(id);
@@ -1031,50 +1075,28 @@ export class ScopedStack {
1031
1075
  // decision a content patch reserves to the owner, reached by another route.
1032
1076
  this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
1033
1077
  record.content[field]);
1034
- // Only a restore that moves the record *into* a container creates a
1035
- // reference. A snapshot at the root names nothing, and a parentId
1036
- // the restore would not change is not re-gated: the record is
1037
- // already there, so a content rollback is not refused over a move
1038
- // it isn't making.
1039
- if (target.parentId !== undefined &&
1040
- target.parentId !== record.parentId &&
1041
- !(await this.canReadReferent(target.parentId))) {
1042
- throw new StackPermissionError();
1043
- }
1044
1078
  await this.requireFileRefAccess(target.typeId, target.content);
1045
- // No association gate: a snapshot never carries `associations`, so
1046
- // a restore never introduces one — see docs/spec/versioning.md
1047
- // § Restore semantics.
1079
+ // Content's file refs are the only reference a restore can put
1080
+ // back: containment and associations are not on a snapshot at all,
1081
+ // so a restore never introduces either — see
1082
+ // docs/spec/versioning.md § Restore semantics.
1048
1083
  }
1049
1084
  }
1050
1085
  return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
1051
1086
  }
1052
1087
  /**
1053
- * Commit a per-record migration — **the owner acting alone, only**.
1054
- *
1055
- * Migration is owner-driven by design: `migrateAll()`, the bulk path,
1056
- * lives on `Stack` and is deliberately absent from `StackClient`, the
1057
- * same way `grant()`/`revoke()` are. This is its per-record counterpart
1058
- * and carries the same restriction, rather than inventing a grant model
1059
- * that the bulk path deliberately doesn't have.
1088
+ * Commit a per-record migration — **the owner acting alone, only**, the
1089
+ * same restriction the bulk `migrateAll()` carries by living on `Stack`.
1060
1090
  *
1061
- * The restriction is what makes the verb safe to expose at all. Migrate
1062
- * replaces `content` and `typeId` wholesale, so a grant-based version
1063
- * would have to re-derive every gate `create()` applies at the
1091
+ * Migrate replaces `content` and `typeId` wholesale, so a grant-based
1092
+ * version would have to re-derive every gate `create()` applies at the
1064
1093
  * destination *and* every gate `mutate()` applies over the existing
1065
- * content, and would reopen each one it missed. The sharpest is the
1066
- * non-owner `_attachment@1` refusal create() carries: without it, a
1067
- * requester holding a create grant on `_attachment@1` and write access
1068
- * to any record they authored could migrate that record into the family
1069
- * naming any `fileId`, then read the bytes through canAccessFile()'s
1070
- * uploader clause — the exact escalation that carve-out exists to refuse
1071
- * (see docs/spec/attachments.md § Creating `_attachment@1` records
1072
- * directly). Ordinary write access to a record is not consent to move it
1073
- * between families.
1074
- *
1075
- * A server implementing `POST /records/:id/migrate` therefore serves it
1076
- * to the stack owner and answers 403 otherwise. See
1077
- * docs/spec/data-model.md § Type migrations.
1094
+ * content, reopening each one it missed. The sharpest is create()'s
1095
+ * non-owner `_attachment@1` refusal: without it, a requester could
1096
+ * migrate a record they authored into the family naming any `fileId` and
1097
+ * read the bytes through canAccessFile()'s uploader clause. Ordinary
1098
+ * write access to a record is not consent to move it between families.
1099
+ * See docs/spec/data-model.md § Type migrations.
1078
1100
  */
1079
1101
  async commitMigration(id, toTypeId, content, opts = {}) {
1080
1102
  this.requireOwnerActingAlone('Only the stack owner may commit a migration');
@@ -1169,7 +1191,7 @@ export class ScopedStack {
1169
1191
  * the stack makes.
1170
1192
  */
1171
1193
  async canReadCached(record, cache) {
1172
- const grants = this.principalEntityId ? await cache.grants(() => this.loadGrants()) : undefined;
1194
+ const grants = this.identified ? await cache.grants(() => this.loadGrants()) : undefined;
1173
1195
  return this.canRead(record, grants, cache.roles);
1174
1196
  }
1175
1197
  }