@haverstack/core 0.13.1 → 0.15.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
@@ -19,7 +19,7 @@ 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
@@ -324,6 +324,78 @@ export function assertValidSort(sort) {
324
324
  throw new StackQueryError(`Invalid sort direction "${sort.direction}": expected "asc" or "desc".`);
325
325
  }
326
326
  }
327
+ /** The identifier spaces a relationship target may name. */
328
+ const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
329
+ /**
330
+ * Collect what makes a relationship target malformed. Absence is
331
+ * meaningful on `stackUrl` and an external `id` — this stack, and the
332
+ * whole namespace — so every part that names something must be non-empty:
333
+ * an empty string stores and matches as though it were absent.
334
+ * See docs/spec/data-model.md § Relationship targets.
335
+ */
336
+ function targetErrors(target, path, opts = {}) {
337
+ const fail = (message) => [{ path, message }];
338
+ if (!target || typeof target !== 'object')
339
+ return fail('A relationship target must be an object.');
340
+ if (!TARGET_SCOPES.has(target.scope)) {
341
+ return fail(`Unknown relationship target scope "${target.scope}": expected "record", "entity" or "external".`);
342
+ }
343
+ if (target.scope === 'record') {
344
+ if (!target.recordId)
345
+ return fail('A record target requires a non-empty recordId.');
346
+ if (target.stackUrl !== undefined && !target.stackUrl) {
347
+ return fail("A record target's stackUrl must be non-empty; omit it to name this stack.");
348
+ }
349
+ return [];
350
+ }
351
+ if (target.scope === 'entity') {
352
+ return target.entityId ? [] : fail('An entity target requires a non-empty entityId.');
353
+ }
354
+ if (!target.ns)
355
+ return fail('An external target requires a non-empty ns.');
356
+ if (target.id === undefined) {
357
+ return opts.externalIdOptional ? [] : fail('An external target requires an id.');
358
+ }
359
+ return target.id ? [] : fail("An external target's id must be non-empty when present.");
360
+ }
361
+ /**
362
+ * Reject a relationship target outside the closed set the types promise.
363
+ * A discriminated union is not a runtime guard — a server mapping a
364
+ * request body onto an association supplies raw JSON — and an
365
+ * unrecognized scope would otherwise be stored under the one arm that
366
+ * names a Record in this stack. See docs/spec/data-model.md
367
+ * § Relationship targets.
368
+ */
369
+ function validateAssociation(association, path = 'association') {
370
+ if (association?.kind !== 'relationship')
371
+ return [];
372
+ return targetErrors(association.target, `${path}.target`);
373
+ }
374
+ /** validateAssociation() over a create's `associations` array. */
375
+ function validateAssociations(associations, path = 'associations') {
376
+ return (associations ?? []).flatMap((a, i) => validateAssociation(a, `${path}[${i}]`));
377
+ }
378
+ /**
379
+ * Reject a relationship filter that names neither a label nor a target,
380
+ * or whose target is malformed. `RelatedToFilter` promises one half is
381
+ * always present; without the runtime check a filter decoded from a
382
+ * request could arrive empty and match every record carrying any
383
+ * relationship. See docs/spec/data-model.md § Filter.
384
+ */
385
+ export function assertValidRelatedTo(relatedTo) {
386
+ if (!relatedTo)
387
+ return;
388
+ if (relatedTo.label === undefined && relatedTo.target === undefined) {
389
+ throw new StackQueryError('filter.relatedTo must name a label, a target, or both — "any relationship at all" is not a filter.');
390
+ }
391
+ if (relatedTo.target === undefined)
392
+ return;
393
+ const errors = targetErrors(relatedTo.target, 'filter.relatedTo.target', {
394
+ externalIdOptional: true,
395
+ });
396
+ if (errors.length > 0)
397
+ throw new StackQueryError(errors[0].message);
398
+ }
327
399
  /**
328
400
  * Thrown when an attachment upload exceeds the adapter's declared
329
401
  * `maxAttachmentBytes` ceiling — checked client-side before any bytes are
@@ -617,7 +689,7 @@ export class Stack {
617
689
  */
618
690
  async ensureOwnerEntity(profile) {
619
691
  const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`;
620
- const existing = await findFirstMatch((q) => this.query(q), { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true } }, (r) => r.content.did === this.ownerEntityId);
692
+ const existing = await findFirstMatch((q) => this.query(q), { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true, includeUnlisted: true } }, (r) => r.content.did === this.ownerEntityId);
621
693
  if (existing)
622
694
  return;
623
695
  await this.create(entityTypeId, {
@@ -826,7 +898,7 @@ export class Stack {
826
898
  let cursor;
827
899
  do {
828
900
  const result = await this.adapter.queryRecords({
829
- filter: { typeId, includeDeleted: true },
901
+ filter: { typeId, includeDeleted: true, includeUnlisted: true },
830
902
  limit: 100,
831
903
  cursor,
832
904
  });
@@ -862,6 +934,7 @@ export class Stack {
862
934
  ...validateReservedKeys(content),
863
935
  ...validateContent(content, type.schema),
864
936
  ...validatePermissions(opts.permissions),
937
+ ...validateAssociations(opts.associations),
865
938
  ];
866
939
  if (errors.length > 0) {
867
940
  throw new StackValidationError(errors);
@@ -895,6 +968,7 @@ export class Stack {
895
968
  ...(opts.principalId && { updatedVia: opts.principalId }),
896
969
  ...(opts.permissions?.length && { permissions: opts.permissions }),
897
970
  ...(associations?.length && { associations }),
971
+ ...(opts.unlisted && { unlistedAt: now }),
898
972
  };
899
973
  const created = await this.adapter.createRecord(record);
900
974
  this.emitChange('create', created);
@@ -998,6 +1072,9 @@ export class Stack {
998
1072
  */
999
1073
  async associate(id, association, opts = {}) {
1000
1074
  this.assertOpen();
1075
+ const errors = validateAssociation(association);
1076
+ if (errors.length > 0)
1077
+ throw new StackValidationError(errors);
1001
1078
  const existing = await this.adapter.getRecord(id);
1002
1079
  if (!existing) {
1003
1080
  throw new StackNotFoundError(`Record not found: "${id}"`);
@@ -1019,6 +1096,9 @@ export class Stack {
1019
1096
  */
1020
1097
  async dissociate(id, association, opts = {}) {
1021
1098
  this.assertOpen();
1099
+ const errors = validateAssociation(association);
1100
+ if (errors.length > 0)
1101
+ throw new StackValidationError(errors);
1022
1102
  const existing = await this.adapter.getRecord(id);
1023
1103
  if (!existing) {
1024
1104
  throw new StackNotFoundError(`Record not found: "${id}"`);
@@ -1060,6 +1140,35 @@ export class Stack {
1060
1140
  });
1061
1141
  this.emitChange('permissions', updated);
1062
1142
  }
1143
+ /**
1144
+ * Withhold a record from enumeration, or restore it. Orthogonal to
1145
+ * setPermissions(): it says nothing about who may read the record, only
1146
+ * whether `query()` and the change feed enumerate it by default. No-op if
1147
+ * already in the requested state. See docs/spec/access-control.md §
1148
+ * Unlisted records.
1149
+ *
1150
+ * The op passed to emitChange() carries the transition direction —
1151
+ * `unlist` (kind `deleted`, so subscribers already holding the record are
1152
+ * told to drop it) or `list` (kind `changed`, an upsert like `undelete`,
1153
+ * for the record's publish moment).
1154
+ */
1155
+ async setUnlisted(id, unlisted, opts = {}) {
1156
+ this.assertOpen();
1157
+ const existing = await this.adapter.getRecord(id);
1158
+ if (!existing) {
1159
+ throw new StackNotFoundError(`Record not found: "${id}"`);
1160
+ }
1161
+ this.checkIfVersion(existing, opts.ifVersion);
1162
+ if (Boolean(existing.unlistedAt) === unlisted)
1163
+ return;
1164
+ const updated = await this.adapter.setUnlisted(id, unlisted, {
1165
+ expectedVersion: opts.ifVersion,
1166
+ snapshot: this.buildVersionSnapshot(existing),
1167
+ updatedBy: opts.updatedBy,
1168
+ updatedVia: opts.updatedVia,
1169
+ });
1170
+ this.emitChange(unlisted ? 'unlist' : 'list', updated);
1171
+ }
1063
1172
  /**
1064
1173
  * Soft-delete a record (default) or hard-delete it permanently, removing
1065
1174
  * the record and its version history. Soft delete snapshots and bumps
@@ -1136,6 +1245,7 @@ export class Stack {
1136
1245
  const { presentAt, filter, limit: rawLimit, ...rest } = query;
1137
1246
  assertQueryCapabilities(filter, this.adapter.capabilities);
1138
1247
  assertValidSort(query.sort);
1248
+ assertValidRelatedTo(filter?.relatedTo);
1139
1249
  const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
1140
1250
  const resolvedFilter = await this.resolveBaseIdFilter(filter);
1141
1251
  if (resolvedFilter === EMPTY_FAMILY) {
@@ -1397,6 +1507,7 @@ export class Stack {
1397
1507
  filter: {
1398
1508
  baseId: family,
1399
1509
  includeDeleted: true,
1510
+ includeUnlisted: true,
1400
1511
  ...(this.features.contentFieldQuery && { content: { [field]: value } }),
1401
1512
  },
1402
1513
  }, (r) => r.id !== excludeId && r.content[field] === value);
@@ -1451,6 +1562,7 @@ export class Stack {
1451
1562
  filter: {
1452
1563
  typeId: metadataTypeId,
1453
1564
  includeDeleted: true,
1565
+ includeUnlisted: true,
1454
1566
  ...(this.features.contentFieldQuery && { content: { fileId } }),
1455
1567
  },
1456
1568
  });
@@ -1608,22 +1720,24 @@ export class Stack {
1608
1720
  * race between the reference check below and the deletes it guards.
1609
1721
  */
1610
1722
  async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts = {}) {
1611
- // A soft-deleted record still counts as a reference — it must find its
1612
- // attachments intact on undelete. See docs/spec/attachments.md
1613
- // § Deleting attachments.
1723
+ // A soft-deleted or unlisted record still counts as a reference — it
1724
+ // must find its attachments intact on undelete or relisting. See
1725
+ // docs/spec/attachments.md § Deleting attachments.
1614
1726
  const refResult = await this.query({
1615
- filter: { attachmentFileId: fileId, includeDeleted: true },
1727
+ filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1616
1728
  limit: 1,
1617
1729
  });
1618
1730
  if (refResult.records.length > 0) {
1619
1731
  throw new StackConflictError('Attachment is still referenced by one or more records');
1620
1732
  }
1621
- // Cursor-walk with includeDeleted: metadata past page one, or soft-
1622
- // deleted, must be cleaned up too — not left pointing at deleted bytes.
1733
+ // Cursor-walk with includeDeleted/includeUnlisted: metadata past page
1734
+ // one, soft-deleted, or unlisted must be cleaned up too — not left
1735
+ // pointing at deleted bytes.
1623
1736
  const metaResults = await queryAllPages((q) => this.query(q), {
1624
1737
  filter: {
1625
1738
  typeId: metadataTypeId,
1626
1739
  includeDeleted: true,
1740
+ includeUnlisted: true,
1627
1741
  ...(this.features.contentFieldQuery && { content: { fileId } }),
1628
1742
  },
1629
1743
  });
@@ -1648,7 +1762,7 @@ export class Stack {
1648
1762
  const now = Date.now();
1649
1763
  const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
1650
1764
  const metaRecords = await queryAllPages((q) => this.query(q), {
1651
- filter: { typeId: metadataTypeId, includeDeleted: true },
1765
+ filter: { typeId: metadataTypeId, includeDeleted: true, includeUnlisted: true },
1652
1766
  });
1653
1767
  // Newest metadata record's createdAt per fileId, and its size (constant
1654
1768
  // across records sharing a fileId, since content-addressing guarantees
@@ -1675,7 +1789,7 @@ export class Stack {
1675
1789
  let reclaimedBytes = 0;
1676
1790
  for (const fileId of candidateFileIds) {
1677
1791
  const refResult = await this.query({
1678
- filter: { attachmentFileId: fileId, includeDeleted: true },
1792
+ filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true },
1679
1793
  limit: 1,
1680
1794
  });
1681
1795
  if (refResult.records.length > 0)
@@ -1761,6 +1875,7 @@ export class Stack {
1761
1875
  const stop = await this.adapter.subscribeChanges({
1762
1876
  ...(opts.filter !== undefined && { filter: opts.filter }),
1763
1877
  ...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }),
1878
+ ...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }),
1764
1879
  ...(opts.onError !== undefined && { onError: opts.onError }),
1765
1880
  ...(opts.onReset !== undefined && { onReset: opts.onReset }),
1766
1881
  }, (change) => delivery.deliver(change));
@@ -2033,17 +2148,31 @@ export class Stack {
2033
2148
  // -------------------------------------------------------
2034
2149
  /**
2035
2150
  * Matches the SQLite adapter's association primary key (kind, label,
2036
- * file_id, related_id).
2151
+ * file_id, related_scope, related_id, related_ns, related_stack).
2037
2152
  */
2038
2153
  function associationEqual(a, b) {
2039
2154
  if (a.kind !== b.kind || a.label !== b.label)
2040
2155
  return false;
2041
2156
  if (a.kind === 'attachment' && b.kind === 'attachment')
2042
2157
  return a.fileId === b.fileId;
2043
- if (a.kind === 'relationship' && b.kind === 'relationship')
2044
- return a.recordId === b.recordId;
2158
+ if (a.kind === 'relationship' && b.kind === 'relationship') {
2159
+ return targetEqual(a.target, b.target);
2160
+ }
2045
2161
  return true;
2046
2162
  }
2163
+ /** Structural equality per target arm — what dissociate() matches on. */
2164
+ export function targetEqual(a, b) {
2165
+ if (a.scope !== b.scope)
2166
+ return false;
2167
+ if (a.scope === 'record' && b.scope === 'record') {
2168
+ return a.recordId === b.recordId && (a.stackUrl ?? '') === (b.stackUrl ?? '');
2169
+ }
2170
+ if (a.scope === 'entity' && b.scope === 'entity')
2171
+ return a.entityId === b.entityId;
2172
+ if (a.scope === 'external' && b.scope === 'external')
2173
+ return a.ns === b.ns && a.id === b.id;
2174
+ return false;
2175
+ }
2047
2176
  function permissionEqual(a, b) {
2048
2177
  if (a.access !== b.access)
2049
2178
  return false;
@@ -2073,10 +2202,16 @@ const MAX_QUERY_LIMIT = 1000;
2073
2202
  */
2074
2203
  function stampGroupAdmin(associations, creator) {
2075
2204
  const list = associations ?? [];
2076
- const alreadyAdmin = list.some((a) => a.kind === 'relationship' && a.label === 'admin' && a.recordId === creator);
2205
+ const alreadyAdmin = list.some((a) => a.kind === 'relationship' &&
2206
+ a.label === 'admin' &&
2207
+ a.target.scope === 'entity' &&
2208
+ a.target.entityId === creator);
2077
2209
  if (alreadyAdmin)
2078
2210
  return list;
2079
- return [...list, { kind: 'relationship', label: 'admin', recordId: creator }];
2211
+ return [
2212
+ ...list,
2213
+ { kind: 'relationship', label: 'admin', target: { scope: 'entity', entityId: creator } },
2214
+ ];
2080
2215
  }
2081
2216
  /**
2082
2217
  * Drops a snapshot's `permissions` — owner-only audit data, never served
@@ -2198,6 +2333,8 @@ class ScopedSubscription extends Subscription {
2198
2333
  async filterAndDeliver(emission) {
2199
2334
  if (this.isClosed)
2200
2335
  return;
2336
+ if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted))
2337
+ return;
2201
2338
  try {
2202
2339
  if (!(await this.canRead(emission.record, this.cache)))
2203
2340
  return;
@@ -2583,10 +2720,15 @@ export class ScopedStack {
2583
2720
  }
2584
2721
  /**
2585
2722
  * Reference-creation gate for one association: `attachment` requires
2586
- * file access, `relationship` read access to its target; `tag` is
2587
- * unchecked, and `_group` roster associations are gated by the stricter
2588
- * isGroupManager() instead. See docs/spec/access-control.md
2589
- * § Reference-creation gating.
2723
+ * file access, and a `relationship` naming a record in this stack
2724
+ * requires read access to it. `tag` is unchecked, and `_group` roster
2725
+ * associations are gated by the stricter isGroupManager() instead.
2726
+ *
2727
+ * The other target arms are ungated because the gate's purpose —
2728
+ * refusing a reference that would convey access to, or confirm the
2729
+ * existence of, an unreadable record — has nothing to bite on: core
2730
+ * never resolves them, so no access flows through one.
2731
+ * See docs/spec/access-control.md § Reference-creation gating.
2590
2732
  */
2591
2733
  async requireAssociationAccess(typeId, association) {
2592
2734
  if (association.kind === 'attachment') {
@@ -2594,7 +2736,14 @@ export class ScopedStack {
2594
2736
  throw new StackPermissionError();
2595
2737
  }
2596
2738
  else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
2597
- if (!(await this.canReadReferent(association.recordId)))
2739
+ const { target } = association;
2740
+ // `stackUrl` is tested for a value, not for presence: absent and
2741
+ // empty are one target — storage, targetEqual() and the filter all
2742
+ // read them as this stack — so a check on presence alone would
2743
+ // leave one spelling of a local Record ungated.
2744
+ if (target.scope !== 'record' || target.stackUrl)
2745
+ return;
2746
+ if (!(await this.canReadReferent(target.recordId)))
2598
2747
  throw new StackPermissionError();
2599
2748
  }
2600
2749
  }
@@ -2626,6 +2775,9 @@ export class ScopedStack {
2626
2775
  if (opts.permissions?.length && !this.mayGrantAccess()) {
2627
2776
  throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
2628
2777
  }
2778
+ if (opts.unlisted && !this.mayGrantAccess()) {
2779
+ throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
2780
+ }
2629
2781
  this.requireOwnerForOwnerDid(typeId, content.did);
2630
2782
  await this.requireAppIdMatchesPrincipal(opts.appId);
2631
2783
  if (opts.id !== undefined) {
@@ -2690,6 +2842,10 @@ export class ScopedStack {
2690
2842
  */
2691
2843
  async query(query = {}) {
2692
2844
  assertValidSort(query.sort);
2845
+ assertValidRelatedTo(query.filter?.relatedTo);
2846
+ if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
2847
+ throw new StackPermissionError('includeUnlisted is owner-only');
2848
+ }
2693
2849
  const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
2694
2850
  const records = [];
2695
2851
  const maxFetched = limit * 10;
@@ -2775,6 +2931,7 @@ export class ScopedStack {
2775
2931
  filter: {
2776
2932
  baseId: SYSTEM_TYPES.APP,
2777
2933
  includeDeleted: true,
2934
+ includeUnlisted: true,
2778
2935
  ...(this.stack.features.contentFieldQuery && {
2779
2936
  content: { did: this.principalEntityId },
2780
2937
  }),
@@ -2851,6 +3008,30 @@ export class ScopedStack {
2851
3008
  }
2852
3009
  return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
2853
3010
  }
3011
+ /**
3012
+ * Withhold a record from enumeration, or restore it — gated exactly like
3013
+ * setPermissions(), since both decide who or what can discover the
3014
+ * record rather than merely read it once found. See
3015
+ * docs/spec/access-control.md § Unlisted records.
3016
+ */
3017
+ async setUnlisted(id, unlisted, opts = {}) {
3018
+ const record = await this.stack.get(id);
3019
+ if (!record)
3020
+ throw new StackNotFoundError(`Record not found: "${id}"`);
3021
+ await this.requireOwnerForGrantRecord(record);
3022
+ if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
3023
+ if (!this.isGroupManager(record))
3024
+ throw await this.denialFor(record);
3025
+ }
3026
+ else {
3027
+ if (!this.mayReshare(this.principalEntityId, record))
3028
+ throw await this.denialFor(record);
3029
+ if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
3030
+ throw await this.denialFor(record);
3031
+ }
3032
+ }
3033
+ return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
3034
+ }
2854
3035
  /**
2855
3036
  * Hard delete is owner-only: it is irreversible and destroys version
2856
3037
  * history, so neither the write bit nor delete-own/delete-any grants
@@ -3032,6 +3213,9 @@ export class ScopedStack {
3032
3213
  'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
3033
3214
  'no record to re-check. Subscribe with a session-scoped stack instead.');
3034
3215
  }
3216
+ if (opts.includeUnlisted && !this.ownerActingAlone) {
3217
+ throw new StackPermissionError('includeUnlisted is owner-only');
3218
+ }
3035
3219
  return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
3036
3220
  }
3037
3221
  /**