@haverstack/core 0.14.0 → 0.16.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.
package/dist/stack.js CHANGED
@@ -13,13 +13,13 @@
13
13
  *
14
14
  * Apps should never talk to a StackAdapter directly.
15
15
  */
16
- import { generateId, isValidIdFormat, idTimestamp } from './id.js';
16
+ import { generateId, generateIdForTimestamp, isValidIdFormat, idTimestamp, MAX_ID_TIMESTAMP, } from './id.js';
17
17
  import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
18
18
  import { validateContent, validateReservedKeys } from './validate.js';
19
19
  import { applyMergePatch } from './merge.js';
20
20
  import { checkAccess, groupRoleFromAssociations, validatePermissions } from './access.js';
21
21
  import { firstRecordedAttachment } from './attachment-download.js';
22
- import { ChangeEmitter, RelayDelivery, Subscription, buildEmission, matchesFilter, } from './changes.js';
22
+ import { ChangeEmitter, RelayDelivery, Subscription, buildEmission, matchesFilter, passesUnlistedBoundary, } from './changes.js';
23
23
  import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
24
24
  // -------------------------------------------------------
25
25
  // Supporting types
@@ -539,19 +539,76 @@ function validateRecordId(id) {
539
539
  }
540
540
  }
541
541
  /**
542
- * Timestamp-prefix plausibility check for grantee-minted IDs
543
- * (ScopedStack.create()) — a grantee is untrusted and could otherwise mint
544
- * an ID that forges its sort position. Pass null to disable.
542
+ * Validity and range check for the backdating options on unscoped
543
+ * Stack.create(). A Date is only as good as what the caller parsed it
544
+ * from, and the two failure modes both need catching here rather than
545
+ * downstream:
546
+ *
547
+ * - **Invalid Date** (`new Date('13/45/2020')` off a malformed import row)
548
+ * has a NaN getTime(), and every comparison against NaN is false — so an
549
+ * unchecked Invalid Date passes the updatedAt/createdAt ordering check
550
+ * and the id/createdAt skew check by turning them off, mints the
551
+ * epoch-zero ID `000000000xxx`, and persists a record whose
552
+ * `createdAt.toISOString()` throws RangeError in serializeRecord() —
553
+ * making that record, and any wire response containing it,
554
+ * permanently unreadable.
555
+ * - **Out of encodable range** — before 1970 crockford32Encode() throws a
556
+ * bare RangeError from deep inside the ID encoder, and past
557
+ * MAX_ID_TIMESTAMP the derived ID silently grows to 13 characters and
558
+ * fails isValidIdFormat().
559
+ *
560
+ * Checked at the door for both, as a StackValidationError naming the
561
+ * field. Applies whether or not an `id` is supplied: a clock field outside
562
+ * this range is unrepresentable regardless of where the ID came from.
563
+ */
564
+ function validateClockField(value, path) {
565
+ if (value === undefined)
566
+ return [];
567
+ // Type-checked callers always pass a Date, but this option is now
568
+ // reachable from the wire: JSON has no Date, so a server that forwards a
569
+ // parsed `POST /records` body hands us the ISO *string* it deserialized.
570
+ // Without this the very next line is `"2020-…".getTime()` — an unhandled
571
+ // TypeError, a 500 where the caller should have got a 400 naming the
572
+ // field.
573
+ if (!(value instanceof Date)) {
574
+ return [{ path, message: `${path} must be a Date.` }];
575
+ }
576
+ const ms = value.getTime();
577
+ if (Number.isNaN(ms)) {
578
+ return [{ path, message: `${path} is not a valid Date.` }];
579
+ }
580
+ if (ms < 0 || ms > MAX_ID_TIMESTAMP) {
581
+ return [
582
+ {
583
+ path,
584
+ message: `${path} is outside the representable range ` +
585
+ `(1970-01-01T00:00:00.000Z…${new Date(MAX_ID_TIMESTAMP).toISOString()}).`,
586
+ },
587
+ ];
588
+ }
589
+ return [];
590
+ }
591
+ /**
592
+ * Timestamp-prefix plausibility check, shared by callers that compare an
593
+ * ID's embedded millisecond against a different reference each:
594
+ * ScopedStack.create() against the current time for any non-backdated
595
+ * create (a grantee is untrusted and could otherwise mint an ID that
596
+ * forges its sort position, and this is the one check standing between a
597
+ * delegated or grantee caller and doing so), and Stack.create() — reached
598
+ * directly when unscoped, or via ScopedStack.create() when the requester
599
+ * is the owner acting alone with an explicit `createdAt` — against that
600
+ * `createdAt` instead (the two must agree, not silently diverge — see
601
+ * docs/spec/data-model.md § Record IDs). Pass null to disable.
545
602
  */
546
- function validateIdTimestampSkew(id, toleranceMs) {
603
+ function validateIdTimestampSkew(id, toleranceMs, referenceMs, referenceLabel) {
547
604
  if (toleranceMs === null)
548
605
  return;
549
- const skew = Math.abs(Date.now() - idTimestamp(id));
606
+ const skew = Math.abs(referenceMs - idTimestamp(id));
550
607
  if (skew > toleranceMs) {
551
608
  throw new StackValidationError([
552
609
  {
553
610
  path: 'id',
554
- message: `ID "${id}" timestamp is outside the allowed clock-skew tolerance (${toleranceMs}ms).`,
611
+ message: `ID "${id}" timestamp disagrees with ${referenceLabel} by more than the allowed clock-skew tolerance (${toleranceMs}ms).`,
555
612
  },
556
613
  ]);
557
614
  }
@@ -689,7 +746,7 @@ export class Stack {
689
746
  */
690
747
  async ensureOwnerEntity(profile) {
691
748
  const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`;
692
- const existing = await findFirstMatch((q) => this.query(q), { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true } }, (r) => r.content.did === this.ownerEntityId);
749
+ const existing = await findFirstMatch((q) => this.query(q), { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true, includeUnlisted: true } }, (r) => r.content.did === this.ownerEntityId);
693
750
  if (existing)
694
751
  return;
695
752
  await this.create(entityTypeId, {
@@ -898,7 +955,7 @@ export class Stack {
898
955
  let cursor;
899
956
  do {
900
957
  const result = await this.adapter.queryRecords({
901
- filter: { typeId, includeDeleted: true },
958
+ filter: { typeId, includeDeleted: true, includeUnlisted: true },
902
959
  limit: 100,
903
960
  cursor,
904
961
  });
@@ -922,7 +979,12 @@ export class Stack {
922
979
  * Create a new record. Validates content against the type's schema.
923
980
  * `_group` records get their author stamped as the first `admin` roster
924
981
  * association here — the single stamping site for both Stack.create()
925
- * and ScopedStack.create(). See docs/spec/identity.md § Group.
982
+ * and ScopedStack.create(). `opts.createdAt`/`updatedAt` let a caller
983
+ * backdate an imported record's clock fields — unconditionally here;
984
+ * ScopedStack.create() forwards to this same method, but only reaches
985
+ * this far with them when the requester is the stack owner acting alone
986
+ * — see BackdatableCreateRecordOptions and docs/spec/data-model.md §
987
+ * Record IDs.
926
988
  */
927
989
  async create(typeId, content, opts = {}) {
928
990
  this.assertOpen();
@@ -930,12 +992,36 @@ export class Stack {
930
992
  if (!type) {
931
993
  throw new Error(`Unknown type: "${typeId}". Call defineType() first.`);
932
994
  }
995
+ // updatedAt defaults to createdAt, not to the actual current time, so a
996
+ // plain import doesn't fabricate a fake edit.
997
+ //
998
+ // Copied, never aliased: the caller keeps its own reference to any Date
999
+ // it passed, and an import loop that reuses one Date across rows
1000
+ // (`d.setTime(...)` per record — the obvious way to write one) would
1001
+ // otherwise retro-edit every record it had already written, with no
1002
+ // version bump and no change event. createdAt drives updatedAt and
1003
+ // unlistedAt below, so one copy taken here covers all three.
1004
+ // `instanceof Date`, not `!== undefined`: this runs before the error
1005
+ // block below, so a non-Date reaching here off the wire would throw a
1006
+ // raw TypeError out of .getTime() before validateClockField() could
1007
+ // report it. Falling back keeps this line total; the error it recorded
1008
+ // still throws below, so the fallback value is never actually stored.
1009
+ const createdAt = opts.createdAt instanceof Date ? new Date(opts.createdAt.getTime()) : new Date();
1010
+ const updatedAt = opts.updatedAt instanceof Date ? new Date(opts.updatedAt.getTime()) : createdAt;
933
1011
  const errors = [
934
1012
  ...validateReservedKeys(content),
935
1013
  ...validateContent(content, type.schema),
936
1014
  ...validatePermissions(opts.permissions),
937
1015
  ...validateAssociations(opts.associations),
1016
+ ...validateClockField(opts.createdAt, 'createdAt'),
1017
+ ...validateClockField(opts.updatedAt, 'updatedAt'),
938
1018
  ];
1019
+ // Compared against the *effective* createdAt, so an updatedAt supplied
1020
+ // on its own is caught too: defaulted-createdAt is now, which a
1021
+ // backdated updatedAt alone would still precede.
1022
+ if (opts.updatedAt !== undefined && updatedAt.getTime() < createdAt.getTime()) {
1023
+ errors.push({ path: 'updatedAt', message: 'updatedAt cannot precede createdAt.' });
1024
+ }
939
1025
  if (errors.length > 0) {
940
1026
  throw new StackValidationError(errors);
941
1027
  }
@@ -944,17 +1030,35 @@ export class Stack {
944
1030
  await this.checkAttachmentMimeTypeOnCreate(content);
945
1031
  }
946
1032
  await this.checkBindingsOnCreate(typeId, content);
947
- if (opts.id !== undefined)
1033
+ if (opts.id !== undefined) {
948
1034
  validateRecordId(opts.id);
1035
+ // Only when both are explicit: an `id` alone (no createdAt) stays a
1036
+ // pure position choice, exactly as before this option existed.
1037
+ if (opts.createdAt !== undefined) {
1038
+ validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
1039
+ }
1040
+ }
949
1041
  const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
950
1042
  ? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
951
1043
  : opts.associations;
952
- const now = new Date();
1044
+ // createdAt (hoisted above, alongside its validation) drives the ID
1045
+ // when the caller doesn't supply one, so the two agree by construction
1046
+ // rather than by coincidence — the same relationship an explicit `id`
1047
+ // is checked against above.
1048
+ // An explicit createdAt mints via generateIdForTimestamp(), which never
1049
+ // clamps to "now" — generateId()'s monotonic floor would otherwise
1050
+ // silently pull a deliberately historical id forward once this process
1051
+ // has minted any live id past it. The no-createdAt path keeps
1052
+ // generateId(), unaffected and still monotonic-safe.
1053
+ const id = opts.id ??
1054
+ (opts.createdAt !== undefined
1055
+ ? generateIdForTimestamp(createdAt.getTime())
1056
+ : generateId(createdAt.getTime()));
953
1057
  const record = {
954
- id: opts.id ?? generateId(),
1058
+ id,
955
1059
  typeId,
956
- createdAt: now,
957
- updatedAt: now,
1060
+ createdAt,
1061
+ updatedAt,
958
1062
  content,
959
1063
  version: 1,
960
1064
  ...(opts.parentId && { parentId: opts.parentId }),
@@ -968,6 +1072,7 @@ export class Stack {
968
1072
  ...(opts.principalId && { updatedVia: opts.principalId }),
969
1073
  ...(opts.permissions?.length && { permissions: opts.permissions }),
970
1074
  ...(associations?.length && { associations }),
1075
+ ...(opts.unlisted && { unlistedAt: createdAt }),
971
1076
  };
972
1077
  const created = await this.adapter.createRecord(record);
973
1078
  this.emitChange('create', created);
@@ -1139,6 +1244,35 @@ export class Stack {
1139
1244
  });
1140
1245
  this.emitChange('permissions', updated);
1141
1246
  }
1247
+ /**
1248
+ * Withhold a record from enumeration, or restore it. Orthogonal to
1249
+ * setPermissions(): it says nothing about who may read the record, only
1250
+ * whether `query()` and the change feed enumerate it by default. No-op if
1251
+ * already in the requested state. See docs/spec/access-control.md §
1252
+ * Unlisted records.
1253
+ *
1254
+ * The op passed to emitChange() carries the transition direction —
1255
+ * `unlist` (kind `deleted`, so subscribers already holding the record are
1256
+ * told to drop it) or `list` (kind `changed`, an upsert like `undelete`,
1257
+ * for the record's publish moment).
1258
+ */
1259
+ async setUnlisted(id, unlisted, opts = {}) {
1260
+ this.assertOpen();
1261
+ const existing = await this.adapter.getRecord(id);
1262
+ if (!existing) {
1263
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1264
+ }
1265
+ this.checkIfVersion(existing, opts.ifVersion);
1266
+ if (Boolean(existing.unlistedAt) === unlisted)
1267
+ return;
1268
+ const updated = await this.adapter.setUnlisted(id, unlisted, {
1269
+ expectedVersion: opts.ifVersion,
1270
+ snapshot: this.buildVersionSnapshot(existing),
1271
+ updatedBy: opts.updatedBy,
1272
+ updatedVia: opts.updatedVia,
1273
+ });
1274
+ this.emitChange(unlisted ? 'unlist' : 'list', updated);
1275
+ }
1142
1276
  /**
1143
1277
  * Soft-delete a record (default) or hard-delete it permanently, removing
1144
1278
  * the record and its version history. Soft delete snapshots and bumps
@@ -1477,6 +1611,7 @@ export class Stack {
1477
1611
  filter: {
1478
1612
  baseId: family,
1479
1613
  includeDeleted: true,
1614
+ includeUnlisted: true,
1480
1615
  ...(this.features.contentFieldQuery && { content: { [field]: value } }),
1481
1616
  },
1482
1617
  }, (r) => r.id !== excludeId && r.content[field] === value);
@@ -1531,6 +1666,7 @@ export class Stack {
1531
1666
  filter: {
1532
1667
  typeId: metadataTypeId,
1533
1668
  includeDeleted: true,
1669
+ includeUnlisted: true,
1534
1670
  ...(this.features.contentFieldQuery && { content: { fileId } }),
1535
1671
  },
1536
1672
  });
@@ -1688,22 +1824,24 @@ export class Stack {
1688
1824
  * race between the reference check below and the deletes it guards.
1689
1825
  */
1690
1826
  async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts = {}) {
1691
- // A soft-deleted record still counts as a reference — it must find its
1692
- // attachments intact on undelete. See docs/spec/attachments.md
1693
- // § Deleting attachments.
1827
+ // A soft-deleted or unlisted record still counts as a reference — it
1828
+ // must find its attachments intact on undelete or relisting. See
1829
+ // docs/spec/attachments.md § Deleting attachments.
1694
1830
  const refResult = await this.query({
1695
- filter: { attachmentFileId: fileId, includeDeleted: true },
1831
+ filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1696
1832
  limit: 1,
1697
1833
  });
1698
1834
  if (refResult.records.length > 0) {
1699
1835
  throw new StackConflictError('Attachment is still referenced by one or more records');
1700
1836
  }
1701
- // Cursor-walk with includeDeleted: metadata past page one, or soft-
1702
- // deleted, must be cleaned up too — not left pointing at deleted bytes.
1837
+ // Cursor-walk with includeDeleted/includeUnlisted: metadata past page
1838
+ // one, soft-deleted, or unlisted must be cleaned up too — not left
1839
+ // pointing at deleted bytes.
1703
1840
  const metaResults = await queryAllPages((q) => this.query(q), {
1704
1841
  filter: {
1705
1842
  typeId: metadataTypeId,
1706
1843
  includeDeleted: true,
1844
+ includeUnlisted: true,
1707
1845
  ...(this.features.contentFieldQuery && { content: { fileId } }),
1708
1846
  },
1709
1847
  });
@@ -1728,7 +1866,7 @@ export class Stack {
1728
1866
  const now = Date.now();
1729
1867
  const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1730
1868
  const metaRecords = await queryAllPages((q) => this.query(q), {
1731
- filter: { typeId: metadataTypeId, includeDeleted: true },
1869
+ filter: { typeId: metadataTypeId, includeDeleted: true, includeUnlisted: true },
1732
1870
  });
1733
1871
  // Newest metadata record's createdAt per fileId, and its size (constant
1734
1872
  // across records sharing a fileId, since content-addressing guarantees
@@ -1755,7 +1893,7 @@ export class Stack {
1755
1893
  let reclaimedBytes = 0;
1756
1894
  for (const fileId of candidateFileIds) {
1757
1895
  const refResult = await this.query({
1758
- filter: { attachmentFileId: fileId, includeDeleted: true },
1896
+ filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1759
1897
  limit: 1,
1760
1898
  });
1761
1899
  if (refResult.records.length > 0)
@@ -1841,6 +1979,7 @@ export class Stack {
1841
1979
  const stop = await this.adapter.subscribeChanges({
1842
1980
  ...(opts.filter !== undefined && { filter: opts.filter }),
1843
1981
  ...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }),
1982
+ ...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }),
1844
1983
  ...(opts.onError !== undefined && { onError: opts.onError }),
1845
1984
  ...(opts.onReset !== undefined && { onReset: opts.onReset }),
1846
1985
  }, (change) => delivery.deliver(change));
@@ -2298,6 +2437,8 @@ class ScopedSubscription extends Subscription {
2298
2437
  async filterAndDeliver(emission) {
2299
2438
  if (this.isClosed)
2300
2439
  return;
2440
+ if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted))
2441
+ return;
2301
2442
  try {
2302
2443
  if (!(await this.canRead(emission.record, this.cache)))
2303
2444
  return;
@@ -2716,12 +2857,29 @@ export class ScopedStack {
2716
2857
  * reference-creating options gated, and non-owner `_attachment@1`
2717
2858
  * creation refused save one carve-out. A scoped create always stamps
2718
2859
  * authorship — an absent entityId means an unscoped `Stack` wrote it.
2719
- * See docs/spec/access-control.md and docs/spec/attachments.md.
2860
+ * `createdAt`/`updatedAt` are refused to everyone but the owner acting
2861
+ * alone — see the guard below and docs/spec/data-model.md § Record IDs.
2862
+ * See also docs/spec/access-control.md and docs/spec/attachments.md.
2720
2863
  */
2721
2864
  async create(typeId, content, opts = {}) {
2722
2865
  const principal = this.principalEntityId;
2723
2866
  if (!principal)
2724
2867
  throw new StackPermissionError('Anonymous requesters cannot create records');
2868
+ // createdAt/updatedAt let a caller backdate a record's clock fields —
2869
+ // and, without `id` also supplied, its sort position too. Refused to
2870
+ // everyone but the owner acting alone (undelegated, authenticated as
2871
+ // themselves): a grantee is exactly the untrusted actor the `id`
2872
+ // skew check below already exists to stop from forging a sort
2873
+ // position, and a delegated app acting for the owner inherits none of
2874
+ // the owner's extra trust — same reasoning as mayGrantAccess() below.
2875
+ // Refused rather than silently dropped, so an app never believes it
2876
+ // published something it didn't. This is also the enforcement a
2877
+ // server built on ScopedStack inherits for `POST /records`: an
2878
+ // owner-authenticated request may carry both fields, anyone else's
2879
+ // has them ignored.
2880
+ if (('createdAt' in opts || 'updatedAt' in opts) && !this.ownerActingAlone) {
2881
+ 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.');
2882
+ }
2725
2883
  if (!(await this.checkCreateGrant(typeId))) {
2726
2884
  throw new StackPermissionError(`No create grant for type "${typeId}"`);
2727
2885
  }
@@ -2738,11 +2896,22 @@ export class ScopedStack {
2738
2896
  if (opts.permissions?.length && !this.mayGrantAccess()) {
2739
2897
  throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
2740
2898
  }
2899
+ if (opts.unlisted && !this.mayGrantAccess()) {
2900
+ throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
2901
+ }
2741
2902
  this.requireOwnerForOwnerDid(typeId, content.did);
2742
2903
  await this.requireAppIdMatchesPrincipal(opts.appId);
2743
2904
  if (opts.id !== undefined) {
2744
2905
  validateRecordId(opts.id);
2745
- validateIdTimestampSkew(opts.id, this.idTimestampSkewMs);
2906
+ // Skipped when createdAt is also supplied: only the owner reaches
2907
+ // here with that combination (checked above), and Stack.create()
2908
+ // below checks the id against createdAt instead of "now" — the
2909
+ // check here exists for a live grantee write, and a backdated
2910
+ // owner create is deliberately not one. See
2911
+ // docs/spec/data-model.md § Record IDs.
2912
+ if (opts.createdAt === undefined) {
2913
+ validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
2914
+ }
2746
2915
  }
2747
2916
  if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
2748
2917
  throw new StackPermissionError();
@@ -2803,6 +2972,9 @@ export class ScopedStack {
2803
2972
  async query(query = {}) {
2804
2973
  assertValidSort(query.sort);
2805
2974
  assertValidRelatedTo(query.filter?.relatedTo);
2975
+ if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
2976
+ throw new StackPermissionError('includeUnlisted is owner-only');
2977
+ }
2806
2978
  const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
2807
2979
  const records = [];
2808
2980
  const maxFetched = limit * 10;
@@ -2888,6 +3060,7 @@ export class ScopedStack {
2888
3060
  filter: {
2889
3061
  baseId: SYSTEM_TYPES.APP,
2890
3062
  includeDeleted: true,
3063
+ includeUnlisted: true,
2891
3064
  ...(this.stack.features.contentFieldQuery && {
2892
3065
  content: { did: this.principalEntityId },
2893
3066
  }),
@@ -2964,6 +3137,30 @@ export class ScopedStack {
2964
3137
  }
2965
3138
  return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
2966
3139
  }
3140
+ /**
3141
+ * Withhold a record from enumeration, or restore it — gated exactly like
3142
+ * setPermissions(), since both decide who or what can discover the
3143
+ * record rather than merely read it once found. See
3144
+ * docs/spec/access-control.md § Unlisted records.
3145
+ */
3146
+ async setUnlisted(id, unlisted, opts = {}) {
3147
+ const record = await this.stack.get(id);
3148
+ if (!record)
3149
+ throw new StackNotFoundError(`Record not found: "${id}"`);
3150
+ await this.requireOwnerForGrantRecord(record);
3151
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3152
+ if (!this.isGroupManager(record))
3153
+ throw await this.denialFor(record);
3154
+ }
3155
+ else {
3156
+ if (!this.mayReshare(this.principalEntityId, record))
3157
+ throw await this.denialFor(record);
3158
+ if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3159
+ throw await this.denialFor(record);
3160
+ }
3161
+ }
3162
+ return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
3163
+ }
2967
3164
  /**
2968
3165
  * Hard delete is owner-only: it is irreversible and destroys version
2969
3166
  * history, so neither the write bit nor delete-own/delete-any grants
@@ -3145,6 +3342,9 @@ export class ScopedStack {
3145
3342
  'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
3146
3343
  'no record to re-check. Subscribe with a session-scoped stack instead.');
3147
3344
  }
3345
+ if (opts.includeUnlisted && !this.ownerActingAlone) {
3346
+ throw new StackPermissionError('includeUnlisted is owner-only');
3347
+ }
3148
3348
  return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
3149
3349
  }
3150
3350
  /**