@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/access.d.ts.map +1 -1
- package/dist/access.js +3 -1
- package/dist/access.js.map +1 -1
- package/dist/adapter-entry.d.ts +1 -1
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +1 -1
- package/dist/adapter-entry.js.map +1 -1
- package/dist/changes.d.ts +14 -0
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +24 -0
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +1 -0
- package/dist/combine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/stack.d.ts +48 -5
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +205 -21
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +4 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +49 -6
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +114 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
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
|
|
1612
|
-
// attachments intact on undelete. See
|
|
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
|
|
1622
|
-
// deleted, must be cleaned up too — not left
|
|
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.
|
|
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' &&
|
|
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 [
|
|
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`
|
|
2587
|
-
* unchecked, and `_group` roster
|
|
2588
|
-
* isGroupManager() instead.
|
|
2589
|
-
*
|
|
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
|
-
|
|
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
|
/**
|