@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/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/id.d.ts +26 -0
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +33 -0
- package/dist/id.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 +77 -9
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +226 -26
- 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 +14 -0
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +43 -1
- 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
|
@@ -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
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
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(
|
|
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
|
|
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().
|
|
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
|
-
|
|
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
|
|
1058
|
+
id,
|
|
955
1059
|
typeId,
|
|
956
|
-
createdAt
|
|
957
|
-
updatedAt
|
|
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
|
|
1692
|
-
// attachments intact on undelete. See
|
|
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
|
|
1702
|
-
// deleted, must be cleaned up too — not left
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
/**
|