@haverstack/core 0.27.0 → 0.29.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/README.md +9 -2
- package/dist/access.d.ts +20 -0
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +26 -0
- package/dist/access.js.map +1 -1
- package/dist/changes.d.ts +12 -2
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +36 -7
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +1 -3
- 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/merge.d.ts +1 -1
- package/dist/merge.js +2 -2
- package/dist/merge.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +56 -5
- package/dist/schema.js.map +1 -1
- package/dist/stack.d.ts +144 -59
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +497 -210
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +3 -10
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +37 -26
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +121 -23
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +31 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +150 -3
- package/dist/validate.js.map +1 -1
- package/dist/wire-entry.d.ts +1 -1
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +1 -1
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-record.d.ts +16 -1
- package/dist/wire-record.d.ts.map +1 -1
- package/dist/wire-record.js +56 -0
- package/dist/wire-record.js.map +1 -1
- package/package.json +2 -2
package/dist/stack.js
CHANGED
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
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
|
-
import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, } from './validate.js';
|
|
18
|
+
import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
|
|
19
19
|
import { applyMergePatch } from './merge.js';
|
|
20
|
-
import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone, validatePermissions, } from './access.js';
|
|
20
|
+
import { checkAccess, groupRoleFromAssociations, hasGroupAdmin, isGroupAdminAssociation, isOwnerActingAlone, validatePermissions, } from './access.js';
|
|
21
21
|
import { compareRecordedAttachments } from './attachment-download.js';
|
|
22
22
|
import { ChangeEmitter, RelayDelivery, Subscription, buildEmission, matchesFilter, passesUnlistedBoundary, } from './changes.js';
|
|
23
|
-
import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
|
|
23
|
+
import { SYSTEM_TYPES, GRANT_ACTIONS, RECORD_CHANGE_KEYS } from './types.js';
|
|
24
24
|
// -------------------------------------------------------
|
|
25
25
|
// Supporting types
|
|
26
26
|
// -------------------------------------------------------
|
|
@@ -532,7 +532,7 @@ function assertAttachmentSize(byteLength, attachmentBytes) {
|
|
|
532
532
|
}
|
|
533
533
|
/**
|
|
534
534
|
* The content half of the same pre-check, on Stack.create() and
|
|
535
|
-
* Stack.
|
|
535
|
+
* Stack.mutate(). Local adapters declare `limits.contentBytes: null` and skip
|
|
536
536
|
* the serialization entirely; only a server declares a ceiling, and its
|
|
537
537
|
* own request-size limit stays authoritative — this just spares an app the
|
|
538
538
|
* round trip and gives it a typed failure instead of a 413 it has to
|
|
@@ -657,6 +657,20 @@ const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
657
657
|
* pagination cursor a StackQueryError rather than a content-validation
|
|
658
658
|
* failure. See StackQueryError's doc comment.
|
|
659
659
|
*/
|
|
660
|
+
/**
|
|
661
|
+
* The same format rule applied to a `parentId` a caller names. Separate
|
|
662
|
+
* from validateRecordId() only for its message: the id under discussion is
|
|
663
|
+
* the destination, not the record being written, and a shared message would
|
|
664
|
+
* report the wrong one. See docs/spec/data-model.md § Reparenting.
|
|
665
|
+
*/
|
|
666
|
+
function validateParentId(parentId) {
|
|
667
|
+
if (parentId.startsWith(RESERVED_ID_PREFIX)) {
|
|
668
|
+
throw new StackQueryError(`Invalid parentId "${parentId}": uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
|
|
669
|
+
}
|
|
670
|
+
if (!isValidIdFormat(parentId)) {
|
|
671
|
+
throw new StackQueryError(`Invalid parentId "${parentId}": expected 12 lowercase Crockford base-32 characters.`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
660
674
|
function validateRecordId(id) {
|
|
661
675
|
if (id.startsWith(RESERVED_ID_PREFIX)) {
|
|
662
676
|
throw new StackQueryError(`ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
|
|
@@ -840,7 +854,7 @@ export class Stack {
|
|
|
840
854
|
/**
|
|
841
855
|
* Announce a mutation that has already been persisted. Called after the
|
|
842
856
|
* adapter write resolves and before the mutating method settles, so
|
|
843
|
-
* `await stack.
|
|
857
|
+
* `await stack.mutate(...)` guarantees subscribers have been notified —
|
|
844
858
|
* and nothing about work they deferred. A handler cannot fail the write:
|
|
845
859
|
* there is nothing left to fail. See docs/spec/events.md § Handlers.
|
|
846
860
|
*/
|
|
@@ -970,7 +984,17 @@ export class Stack {
|
|
|
970
984
|
const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
|
|
971
985
|
if (parsed.version > priorMax)
|
|
972
986
|
this.maxDefinedVersion.set(parsed.baseId, parsed.version);
|
|
973
|
-
|
|
987
|
+
// Shape first: the name checks and hashSchema() below both read the
|
|
988
|
+
// schema as a well-formed one, and a schema off the wire is parsed
|
|
989
|
+
// JSON that no compiler has seen.
|
|
990
|
+
const shapeErrors = validateSchemaShape(schema);
|
|
991
|
+
if (shapeErrors.length > 0) {
|
|
992
|
+
throw new StackValidationError(shapeErrors);
|
|
993
|
+
}
|
|
994
|
+
const nameErrors = [
|
|
995
|
+
...validateSchemaReservedNames(schema),
|
|
996
|
+
...validateSchemaFieldNames(schema),
|
|
997
|
+
];
|
|
974
998
|
if (nameErrors.length > 0) {
|
|
975
999
|
throw new StackValidationError(nameErrors);
|
|
976
1000
|
}
|
|
@@ -1193,6 +1217,13 @@ export class Stack {
|
|
|
1193
1217
|
if (opts.createdAt !== undefined) {
|
|
1194
1218
|
validateIdTimestampSkew(opts.id, this.idTimestampSkewMsValue, opts.createdAt.getTime(), 'createdAt');
|
|
1195
1219
|
}
|
|
1220
|
+
// A generated id names nothing, so a create under a parent cannot
|
|
1221
|
+
// close a loop. A caller-supplied one can: existing records may
|
|
1222
|
+
// already point at it, and the chain above the parent can lead back
|
|
1223
|
+
// to it — so the walk is asked only for that combination.
|
|
1224
|
+
// See docs/spec/data-model.md § Reparenting.
|
|
1225
|
+
if (opts.parentId !== undefined)
|
|
1226
|
+
await this.assertNoParentCycle(opts.id, opts.parentId);
|
|
1196
1227
|
}
|
|
1197
1228
|
const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
|
|
1198
1229
|
? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
|
|
@@ -1210,6 +1241,20 @@ export class Stack {
|
|
|
1210
1241
|
(opts.createdAt !== undefined
|
|
1211
1242
|
? generateIdForTimestamp(createdAt.getTime())
|
|
1212
1243
|
: generateId(createdAt.getTime()));
|
|
1244
|
+
// An id field a caller supplies is a value or it is absent — never the
|
|
1245
|
+
// empty string, which names nobody. Refused rather than dropped: an
|
|
1246
|
+
// ignored field is the silent-normalization this mapper family was just
|
|
1247
|
+
// fixed for, one layer up.
|
|
1248
|
+
for (const field of ['entityId', 'appId', 'principalId']) {
|
|
1249
|
+
if (opts[field] === '') {
|
|
1250
|
+
throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// Every create naming a parent owes the reference check, whether or not
|
|
1254
|
+
// it supplied an id: a destination a caller names has to be one that
|
|
1255
|
+
// exists. This is the read an ordinary create used to skip.
|
|
1256
|
+
if (opts.parentId !== undefined)
|
|
1257
|
+
await this.assertParentExists(id, opts.parentId);
|
|
1213
1258
|
const record = {
|
|
1214
1259
|
id,
|
|
1215
1260
|
typeId,
|
|
@@ -1217,10 +1262,14 @@ export class Stack {
|
|
|
1217
1262
|
updatedAt,
|
|
1218
1263
|
content,
|
|
1219
1264
|
version: 1,
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1265
|
+
// Read for presence, not truthiness — every one of these is checked
|
|
1266
|
+
// above, so '' never reaches here and absence is the only thing a
|
|
1267
|
+
// falsy value could mean. Presence says that outright instead of
|
|
1268
|
+
// relying on it.
|
|
1269
|
+
...(opts.parentId !== undefined && { parentId: opts.parentId }),
|
|
1270
|
+
...(opts.entityId !== undefined && { entityId: opts.entityId }),
|
|
1271
|
+
...(opts.appId !== undefined && { appId: opts.appId }),
|
|
1272
|
+
...(opts.principalId !== undefined && { principalId: opts.principalId }),
|
|
1224
1273
|
// A create's actor is its author, so these are derived rather than
|
|
1225
1274
|
// taken: stamping them here keeps "absent means an unscoped write"
|
|
1226
1275
|
// true of version 1 as it is of every later version.
|
|
@@ -1275,64 +1324,140 @@ export class Stack {
|
|
|
1275
1324
|
return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
|
|
1276
1325
|
}
|
|
1277
1326
|
/**
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1327
|
+
* Apply a change set — content patch, `parentId`, `permissions`,
|
|
1328
|
+
* `associations`, `unlisted`, in any combination — as one atomic write
|
|
1329
|
+
* producing one version and one snapshot. Keys are read for presence, so
|
|
1330
|
+
* `unlisted: false` and `parentId: null` are changes.
|
|
1331
|
+
*
|
|
1332
|
+
* Every key present is checked against the record as it stands, and a
|
|
1333
|
+
* change set already satisfied in all of them writes nothing and returns
|
|
1334
|
+
* the record unchanged. The ops the change event carries are derived
|
|
1335
|
+
* from that same comparison, so naming an aspect without moving it is
|
|
1336
|
+
* never reported as moving it.
|
|
1337
|
+
*
|
|
1338
|
+
* Content is validated against the record's *current* stored type and
|
|
1339
|
+
* `typeId` never changes (see docs/spec/data-model.md § Type
|
|
1340
|
+
* migrations). See docs/spec/data-model.md § Mutations.
|
|
1283
1341
|
*/
|
|
1284
|
-
async
|
|
1342
|
+
async mutate(id, changes, opts = {}) {
|
|
1285
1343
|
this.assertOpen();
|
|
1344
|
+
assertNonEmptyChangeSet(changes);
|
|
1286
1345
|
const existing = await this.adapter.getRecord(id);
|
|
1287
1346
|
if (!existing) {
|
|
1288
1347
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1289
1348
|
}
|
|
1290
1349
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
const
|
|
1301
|
-
...validateReservedKeys(content),
|
|
1302
|
-
...validatePatchValues(content),
|
|
1303
|
-
...validateContentKeys(content),
|
|
1304
|
-
];
|
|
1305
|
-
if (patchErrors.length > 0) {
|
|
1306
|
-
throw new StackValidationError(patchErrors);
|
|
1307
|
-
}
|
|
1308
|
-
// The patch is what travels, so the patch is what's measured — a small
|
|
1309
|
-
// patch against a large record is not an oversized request.
|
|
1310
|
-
assertContentSize(content, this.features.limits.contentBytes, 'Patch');
|
|
1311
|
-
// Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
|
|
1312
|
-
const merged = applyMergePatch(existing.content, content);
|
|
1313
|
-
const errors = validateContent(merged, type.schema);
|
|
1314
|
-
if (errors.length > 0) {
|
|
1315
|
-
throw new StackValidationError(errors);
|
|
1316
|
-
}
|
|
1317
|
-
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
1318
|
-
this.checkAttachmentImmutableFields(content, existing.content, merged);
|
|
1319
|
-
}
|
|
1320
|
-
await this.checkBindingsOnUpdate(existing.typeId, id, content, existing.content, merged);
|
|
1321
|
-
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1322
|
-
this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
1323
|
-
}
|
|
1324
|
-
const updated = await this.adapter.patchContent(id, content, {
|
|
1350
|
+
const merged = await this.validateChangeSet(id, existing, changes);
|
|
1351
|
+
const ops = changeSetOps(existing, changes, merged);
|
|
1352
|
+
// Nothing moved, so there is nothing to version. Decided here rather
|
|
1353
|
+
// than in the adapter: "did this change anything" is a question about
|
|
1354
|
+
// the record's meaning, and an adapter is never handed a write that
|
|
1355
|
+
// writes nothing.
|
|
1356
|
+
if (ops.length === 0)
|
|
1357
|
+
return existing;
|
|
1358
|
+
const previousParentId = existing.parentId ?? null;
|
|
1359
|
+
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
|
|
1325
1360
|
expectedVersion: opts.ifVersion,
|
|
1326
1361
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1327
1362
|
updatedBy: opts.updatedBy,
|
|
1328
1363
|
updatedVia: opts.updatedVia,
|
|
1329
1364
|
});
|
|
1330
|
-
this.emitChange(
|
|
1365
|
+
this.emitChange(ops, updated, ops.includes('reparent') ? { previousParentId } : {});
|
|
1331
1366
|
return updated;
|
|
1332
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* mutate() with `contentPatch` alone. The common case by a wide margin,
|
|
1370
|
+
* and named for what it does rather than for a symmetry with create()
|
|
1371
|
+
* that a patch does not have.
|
|
1372
|
+
*/
|
|
1373
|
+
async patchContent(id, patch, opts = {}) {
|
|
1374
|
+
return this.mutate(id, { contentPatch: patch }, opts);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Every check a change set owes before anything is written, in one pass:
|
|
1378
|
+
* one refused key refuses the call, so none of them may run after a
|
|
1379
|
+
* partial write. Returns the merged content when the set carries a
|
|
1380
|
+
* content patch, since the caller needs it to decide whether content
|
|
1381
|
+
* actually moved.
|
|
1382
|
+
*
|
|
1383
|
+
* Parent existence and acyclicity are asked only when the destination
|
|
1384
|
+
* differs from where the record already sits — a move to where it
|
|
1385
|
+
* already is names no new edge, and would otherwise cost a read apiece.
|
|
1386
|
+
*/
|
|
1387
|
+
async validateChangeSet(id, existing, changes) {
|
|
1388
|
+
const { contentPatch, permissions, associations, parentId } = changes;
|
|
1389
|
+
const errors = [
|
|
1390
|
+
...(contentPatch
|
|
1391
|
+
? [
|
|
1392
|
+
...validateReservedKeys(contentPatch),
|
|
1393
|
+
...validatePatchValues(contentPatch),
|
|
1394
|
+
...validateContentKeys(contentPatch),
|
|
1395
|
+
]
|
|
1396
|
+
: []),
|
|
1397
|
+
...(permissions ? validatePermissions(permissions) : []),
|
|
1398
|
+
...(associations ? validateAssociations(associations) : []),
|
|
1399
|
+
];
|
|
1400
|
+
if (errors.length > 0)
|
|
1401
|
+
throw new StackValidationError(errors);
|
|
1402
|
+
let merged;
|
|
1403
|
+
if (contentPatch) {
|
|
1404
|
+
// The patch is what travels, so the patch is what's measured — a
|
|
1405
|
+
// small patch against a large record is not an oversized request.
|
|
1406
|
+
assertContentSize(contentPatch, this.features.limits.contentBytes, 'Patch');
|
|
1407
|
+
const type = await this.getTypeCached(existing.typeId);
|
|
1408
|
+
if (!type) {
|
|
1409
|
+
throw new StackQueryError(`Unknown type: "${existing.typeId}"`);
|
|
1410
|
+
}
|
|
1411
|
+
merged = applyMergePatch(existing.content, contentPatch);
|
|
1412
|
+
const contentErrors = validateContent(merged, type.schema);
|
|
1413
|
+
if (contentErrors.length > 0)
|
|
1414
|
+
throw new StackValidationError(contentErrors);
|
|
1415
|
+
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
1416
|
+
this.checkAttachmentImmutableFields(contentPatch, existing.content, merged);
|
|
1417
|
+
}
|
|
1418
|
+
await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged);
|
|
1419
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1420
|
+
this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (associations !== undefined) {
|
|
1424
|
+
this.assertGroupAdminRemains(existing, associations);
|
|
1425
|
+
}
|
|
1426
|
+
if (parentId !== undefined && parentId !== null && parentId !== (existing.parentId ?? null)) {
|
|
1427
|
+
await this.assertParentExists(id, parentId);
|
|
1428
|
+
await this.assertNoParentCycle(id, parentId);
|
|
1429
|
+
}
|
|
1430
|
+
return merged;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Refuse a write that would leave a `_group` Record with no `admin` on
|
|
1434
|
+
* its roster. Asked of the roster the write would *produce* — the one
|
|
1435
|
+
* place a change-set check reads the post-state rather than the
|
|
1436
|
+
* pre-state, because the rule is about what is left behind rather than
|
|
1437
|
+
* what the caller named.
|
|
1438
|
+
*
|
|
1439
|
+
* That framing is the whole of the self-removal question: an `admin`
|
|
1440
|
+
* removing themselves passes while another remains and is refused when
|
|
1441
|
+
* they are the last, and neither case needs to know whose entry went.
|
|
1442
|
+
*
|
|
1443
|
+
* Lives here rather than in `ScopedStack` deliberately. Who may write a
|
|
1444
|
+
* `_group` is a permission question and belongs to the gate up there;
|
|
1445
|
+
* this is an integrity constraint on the Record, so it binds every
|
|
1446
|
+
* requester — the stack owner included, who would otherwise be the hole
|
|
1447
|
+
* that stops anything downstream relying on the invariant.
|
|
1448
|
+
* See docs/spec/identity.md § Group.
|
|
1449
|
+
*/
|
|
1450
|
+
assertGroupAdminRemains(record, next) {
|
|
1451
|
+
if (!isGroupRecord(record))
|
|
1452
|
+
return;
|
|
1453
|
+
if (hasGroupAdmin(next))
|
|
1454
|
+
return;
|
|
1455
|
+
throw new StackConflictError(`Cannot leave group "${record.id}" without an admin: a _group record's roster keeps at ` +
|
|
1456
|
+
'least one `admin` relationship association. Name the incoming admin in the same write.');
|
|
1457
|
+
}
|
|
1333
1458
|
/**
|
|
1334
1459
|
* Add an association to a record. Snapshots the record's prior state and
|
|
1335
|
-
* bumps version, same as
|
|
1460
|
+
* bumps version, same as patchContent() — associations are covered by the same
|
|
1336
1461
|
* versioning rule as content.
|
|
1337
1462
|
* If the association already exists (same kind, label, and payload), this
|
|
1338
1463
|
* is a no-op. Returns the record as it now stands — unchanged on a no-op.
|
|
@@ -1377,6 +1502,13 @@ export class Stack {
|
|
|
1377
1502
|
if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) {
|
|
1378
1503
|
return existing;
|
|
1379
1504
|
}
|
|
1505
|
+
// Removing anything else cannot take the roster to zero, so only an
|
|
1506
|
+
// admin entry is worth deriving the post-state for. Derived rather than
|
|
1507
|
+
// counted so this path and the change set's reach the invariant through
|
|
1508
|
+
// the same helper, asking the same question of the same shape of list.
|
|
1509
|
+
if (isGroupAdminAssociation(association)) {
|
|
1510
|
+
this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
|
|
1511
|
+
}
|
|
1380
1512
|
const updated = await this.adapter.dissociate(id, association, {
|
|
1381
1513
|
expectedVersion: opts.ifVersion,
|
|
1382
1514
|
snapshot: this.buildVersionSnapshot(existing),
|
|
@@ -1387,63 +1519,59 @@ export class Stack {
|
|
|
1387
1519
|
return updated;
|
|
1388
1520
|
}
|
|
1389
1521
|
/**
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
|
|
1395
|
-
async setPermissions(id, permissions, opts = {}) {
|
|
1396
|
-
this.assertOpen();
|
|
1397
|
-
const errors = validatePermissions(permissions);
|
|
1398
|
-
if (errors.length > 0) {
|
|
1399
|
-
throw new StackValidationError(errors);
|
|
1400
|
-
}
|
|
1401
|
-
const existing = await this.adapter.getRecord(id);
|
|
1402
|
-
if (!existing) {
|
|
1403
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1404
|
-
}
|
|
1405
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1406
|
-
if (permissionsEqual(existing.permissions ?? [], permissions))
|
|
1407
|
-
return existing;
|
|
1408
|
-
const updated = await this.adapter.setPermissions(id, permissions, {
|
|
1409
|
-
expectedVersion: opts.ifVersion,
|
|
1410
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1411
|
-
updatedBy: opts.updatedBy,
|
|
1412
|
-
updatedVia: opts.updatedVia,
|
|
1413
|
-
});
|
|
1414
|
-
this.emitChange('permissions', updated);
|
|
1415
|
-
return updated;
|
|
1416
|
-
}
|
|
1417
|
-
/**
|
|
1418
|
-
* Withhold a record from enumeration, or restore it. Orthogonal to
|
|
1419
|
-
* setPermissions(): it says nothing about who may read the record, only
|
|
1420
|
-
* whether `query()` and the change feed enumerate it by default. No-op if
|
|
1421
|
-
* already in the requested state. Returns the record as it now stands —
|
|
1422
|
-
* unchanged on a no-op. See docs/spec/access-control.md §
|
|
1423
|
-
* Unlisted records.
|
|
1522
|
+
* Refuse an edge that would make a record its own ancestor — asked at
|
|
1523
|
+
* every site that adds one: a change set naming `parentId`, a restore
|
|
1524
|
+
* putting a container back, and a create naming both its own id and a parent. Nothing
|
|
1525
|
+
* downstream (a generator deriving a page path, a folder view) is
|
|
1526
|
+
* written to survive a cycle.
|
|
1424
1527
|
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1528
|
+
* Walks with the unscoped adapter deliberately: a walk that skipped the
|
|
1529
|
+
* links a requester cannot read would let a cycle be assembled through
|
|
1530
|
+
* them and break the invariant for every reader. The walk is bounded
|
|
1531
|
+
* because a chain long enough to exhaust it is already pathological.
|
|
1532
|
+
*
|
|
1533
|
+
* Read-then-write, so two moves racing on opposite ends of one chain can
|
|
1534
|
+
* both pass — closing that means the invariant lives in the adapter,
|
|
1535
|
+
* where the write is atomic, which puts a graph constraint in the
|
|
1536
|
+
* storage contract every adapter then implements. Same deferral as
|
|
1537
|
+
* checkBindingUnique() above. Consumers that walk `parentId` should
|
|
1538
|
+
* carry a visited set rather than trust this alone; MAX_PARENT_DEPTH is
|
|
1539
|
+
* the same posture applied here.
|
|
1540
|
+
* See docs/spec/data-model.md § Reparenting.
|
|
1541
|
+
*/
|
|
1542
|
+
/**
|
|
1543
|
+
* The checks a *caller-named* destination owes, before the cycle walk:
|
|
1544
|
+
* `parentId` is a real, well-formed record id. Format first, so a
|
|
1545
|
+
* malformed one is a 400 naming the problem rather than a read that
|
|
1546
|
+
* cannot match. Existence closes the gap between the owner path and a
|
|
1547
|
+
* non-owner's, where canReadReferent() already refuses a parent that
|
|
1548
|
+
* isn't there.
|
|
1549
|
+
*
|
|
1550
|
+
* restoreVersion() deliberately does not call this — see its own comment.
|
|
1551
|
+
* See docs/spec/data-model.md § Reparenting.
|
|
1552
|
+
*/
|
|
1553
|
+
async assertParentExists(id, parentId) {
|
|
1554
|
+
validateParentId(parentId);
|
|
1555
|
+
if (!(await this.adapter.getRecord(parentId))) {
|
|
1556
|
+
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
|
|
1557
|
+
'exist when it is named.');
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
async assertNoParentCycle(id, parentId) {
|
|
1561
|
+
let cursor = parentId;
|
|
1562
|
+
for (let depth = 0; cursor !== undefined; depth++) {
|
|
1563
|
+
if (cursor === id) {
|
|
1564
|
+
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": it is a descendant of "${id}", ` +
|
|
1565
|
+
'and the move would make the record its own ancestor.');
|
|
1566
|
+
}
|
|
1567
|
+
// The cap bounds work, and guarantees this terminates on a chain that
|
|
1568
|
+
// is already cyclic. It does not refuse the move: a chain this long is
|
|
1569
|
+
// past what the check can speak to, not evidence of a loop, and
|
|
1570
|
+
// refusing it would claim an invariant core does not maintain.
|
|
1571
|
+
if (depth >= MAX_PARENT_DEPTH)
|
|
1572
|
+
return;
|
|
1573
|
+
cursor = (await this.adapter.getRecord(cursor))?.parentId;
|
|
1435
1574
|
}
|
|
1436
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1437
|
-
if (Boolean(existing.unlistedAt) === unlisted)
|
|
1438
|
-
return existing;
|
|
1439
|
-
const updated = await this.adapter.setUnlisted(id, unlisted, {
|
|
1440
|
-
expectedVersion: opts.ifVersion,
|
|
1441
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1442
|
-
updatedBy: opts.updatedBy,
|
|
1443
|
-
updatedVia: opts.updatedVia,
|
|
1444
|
-
});
|
|
1445
|
-
this.emitChange(unlisted ? 'unlist' : 'list', updated);
|
|
1446
|
-
return updated;
|
|
1447
1575
|
}
|
|
1448
1576
|
/**
|
|
1449
1577
|
* Soft-delete a record (default) or hard-delete it permanently, removing
|
|
@@ -1571,9 +1699,10 @@ export class Stack {
|
|
|
1571
1699
|
/**
|
|
1572
1700
|
* Restore a record to a previous version by creating a new version —
|
|
1573
1701
|
* never rewrites history. The snapshot is validated against its own
|
|
1574
|
-
* stored typeId (not the record's current type), restores associations
|
|
1575
|
-
* and
|
|
1576
|
-
*
|
|
1702
|
+
* stored typeId (not the record's current type), restores associations
|
|
1703
|
+
* and `parentId` (absent on the snapshot is the root, so a restore
|
|
1704
|
+
* always settles containment), and never restores permissions. See
|
|
1705
|
+
* docs/spec/versioning.md § Restore semantics.
|
|
1577
1706
|
*/
|
|
1578
1707
|
async restoreVersion(id, version, opts = {}) {
|
|
1579
1708
|
this.assertOpen();
|
|
@@ -1598,31 +1727,50 @@ export class Stack {
|
|
|
1598
1727
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
|
|
1599
1728
|
}
|
|
1600
1729
|
// Restoring is a write like any other, so it owes the same immutability
|
|
1601
|
-
// check
|
|
1730
|
+
// check a content patch pays — a snapshot taken before a card claimed its DID
|
|
1602
1731
|
// would otherwise move the binding by rolling content back. Uniqueness
|
|
1603
1732
|
// needs no separate check here: a restore can only put back a value this
|
|
1604
1733
|
// same card already held, which immutability already refuses to change.
|
|
1605
1734
|
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
1606
1735
|
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
1607
1736
|
}
|
|
1737
|
+
// A restore that puts a container back is an edge-adding site like
|
|
1738
|
+
// a change set's `parentId`, and the chain above that container may have moved since
|
|
1739
|
+
// the snapshot was taken. The cycle walk therefore applies — but
|
|
1740
|
+
// assertParentExists() deliberately does not: a restore is not a caller
|
|
1741
|
+
// naming a destination, it is history being put back, and the container
|
|
1742
|
+
// may have been hard-deleted since. Refusing here would make an
|
|
1743
|
+
// unrelated deletion cost the record its content rollback, which is the
|
|
1744
|
+
// recoverability this whole verb exists for. A dangling parentId is
|
|
1745
|
+
// legal at rest anyway — deleting a container never touches its
|
|
1746
|
+
// children. See docs/spec/versioning.md § Restore semantics.
|
|
1747
|
+
const previousParentId = existing.parentId ?? null;
|
|
1748
|
+
const targetParentId = target.parentId ?? null;
|
|
1749
|
+
const moves = targetParentId !== previousParentId;
|
|
1750
|
+
if (moves && targetParentId !== null) {
|
|
1751
|
+
await this.assertNoParentCycle(id, targetParentId);
|
|
1752
|
+
}
|
|
1753
|
+
// A `_group`'s roster is authority, not data: it does not roll back.
|
|
1754
|
+
// See docs/spec/versioning.md § Restore semantics.
|
|
1608
1755
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1609
1756
|
expectedVersion: opts.ifVersion,
|
|
1610
1757
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1611
1758
|
updatedBy: opts.updatedBy,
|
|
1612
1759
|
updatedVia: opts.updatedVia,
|
|
1760
|
+
...(isGroupRecord(existing) && { restoreAssociations: false }),
|
|
1613
1761
|
});
|
|
1614
|
-
this.emitChange('restore', restored);
|
|
1762
|
+
this.emitChange('restore', restored, moves ? { previousParentId } : {});
|
|
1615
1763
|
return restored;
|
|
1616
1764
|
}
|
|
1617
1765
|
/**
|
|
1618
1766
|
* Commit a per-record migration: replace `content` and `typeId` together
|
|
1619
1767
|
* in one step, validated against `toTypeId`'s schema exactly as
|
|
1620
|
-
* create()/
|
|
1768
|
+
* create()/mutate() validate against a type's schema. The single-record
|
|
1621
1769
|
* counterpart to migrateAll() — content here is supplied by the caller
|
|
1622
1770
|
* (computed client-side by the type's owning app, per
|
|
1623
1771
|
* docs/spec/wire-format.md § Migration commit) rather than a registered
|
|
1624
1772
|
* Migration function. Snapshots the prior state to version history, same
|
|
1625
|
-
* as
|
|
1773
|
+
* as mutate()/restoreVersion(), and takes the same optional `ifVersion`
|
|
1626
1774
|
* precondition every version-bumping mutation takes — checked atomically
|
|
1627
1775
|
* at the adapter, not here (see docs/spec/versioning.md § Optimistic
|
|
1628
1776
|
* concurrency).
|
|
@@ -1632,7 +1780,7 @@ export class Stack {
|
|
|
1632
1780
|
* record as it stands, so it owes both sets of integrity checks — the
|
|
1633
1781
|
* binding rules, the attachment rules, and `_config`'s. Missing either
|
|
1634
1782
|
* half would make migrate a second, unguarded write path to the same
|
|
1635
|
-
* state create()/
|
|
1783
|
+
* state create()/mutate() refuse to reach.
|
|
1636
1784
|
*/
|
|
1637
1785
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1638
1786
|
this.assertOpen();
|
|
@@ -1718,7 +1866,7 @@ export class Stack {
|
|
|
1718
1866
|
/**
|
|
1719
1867
|
* Immutability for every binding field a patch touches, then uniqueness
|
|
1720
1868
|
* for the subset that carries it. Fields absent from the patch carry no
|
|
1721
|
-
* new claim —
|
|
1869
|
+
* new claim — a content patch is a merge, so an untouched binding is the one the
|
|
1722
1870
|
* card already holds.
|
|
1723
1871
|
*/
|
|
1724
1872
|
async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
|
|
@@ -1909,7 +2057,7 @@ export class Stack {
|
|
|
1909
2057
|
}
|
|
1910
2058
|
}
|
|
1911
2059
|
/**
|
|
1912
|
-
* `_config.entityId` defines stack ownership; neither
|
|
2060
|
+
* `_config.entityId` defines stack ownership; neither patchContent() nor
|
|
1913
2061
|
* restoreVersion() may change it. A conflict with stack integrity, not a
|
|
1914
2062
|
* schema violation — hence StackConflictError. See docs/spec.md § The
|
|
1915
2063
|
* `_config` record.
|
|
@@ -2424,8 +2572,9 @@ export class Stack {
|
|
|
2424
2572
|
/**
|
|
2425
2573
|
* Snapshot of a record's prior state, passed with the mutating adapter
|
|
2426
2574
|
* call so snapshot and mutation land in one atomic write. `associations`
|
|
2427
|
-
*
|
|
2428
|
-
*
|
|
2575
|
+
* and `parentId` are always present (`[]` and `null` where the record has
|
|
2576
|
+
* neither) so restore can distinguish "cleared" and "at the root" from a
|
|
2577
|
+
* snapshot that omits the key entirely ("leave as-is"). See
|
|
2429
2578
|
* docs/spec/versioning.md § Version history.
|
|
2430
2579
|
*/
|
|
2431
2580
|
buildVersionSnapshot(record) {
|
|
@@ -2437,6 +2586,7 @@ export class Stack {
|
|
|
2437
2586
|
...(record.entityId && { entityId: record.entityId }),
|
|
2438
2587
|
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
2439
2588
|
...(record.updatedVia && { updatedVia: record.updatedVia }),
|
|
2589
|
+
...(record.parentId !== undefined && { parentId: record.parentId }),
|
|
2440
2590
|
associations: record.associations ?? [],
|
|
2441
2591
|
...(record.permissions && { permissions: record.permissions }),
|
|
2442
2592
|
};
|
|
@@ -2485,6 +2635,89 @@ function permissionEqual(a, b) {
|
|
|
2485
2635
|
}
|
|
2486
2636
|
return false;
|
|
2487
2637
|
}
|
|
2638
|
+
/**
|
|
2639
|
+
* A change set has to name at least one aspect. Refused rather than read
|
|
2640
|
+
* as a no-op: it addresses nothing, so there is nothing it could have
|
|
2641
|
+
* failed to satisfy, and every way of producing one is a caller bug —
|
|
2642
|
+
* typically a conditional that built an empty object.
|
|
2643
|
+
* See docs/spec/data-model.md § Mutations.
|
|
2644
|
+
*/
|
|
2645
|
+
function assertNonEmptyChangeSet(changes) {
|
|
2646
|
+
// Presence, not truthiness: `unlisted: false` and `parentId: null` are
|
|
2647
|
+
// aspects this call names, and reading them as absent would drop a
|
|
2648
|
+
// change the caller asked for.
|
|
2649
|
+
if (RECORD_CHANGE_KEYS.some((key) => changes[key] !== undefined))
|
|
2650
|
+
return;
|
|
2651
|
+
throw new StackQueryError('A change set names at least one of: ' + RECORD_CHANGE_KEYS.join(', ') + '.');
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Which aspects a change set actually moves, against the record as it
|
|
2655
|
+
* stands — the no-op decision and the change event's `ops` are the same
|
|
2656
|
+
* comparison, so they can never disagree. A key naming the value a record
|
|
2657
|
+
* already holds contributes nothing.
|
|
2658
|
+
*
|
|
2659
|
+
* `merged` is the content the patch produces, computed once by the
|
|
2660
|
+
* caller that had to validate it anyway.
|
|
2661
|
+
* See docs/spec/events.md § The event shape.
|
|
2662
|
+
*/
|
|
2663
|
+
function changeSetOps(existing, changes, merged) {
|
|
2664
|
+
const ops = [];
|
|
2665
|
+
if (merged !== undefined && !contentEqual(existing.content, merged))
|
|
2666
|
+
ops.push('patch');
|
|
2667
|
+
if (changes.parentId !== undefined && changes.parentId !== (existing.parentId ?? null)) {
|
|
2668
|
+
ops.push('reparent');
|
|
2669
|
+
}
|
|
2670
|
+
if (changes.permissions && !permissionsEqual(existing.permissions ?? [], changes.permissions)) {
|
|
2671
|
+
ops.push('permissions');
|
|
2672
|
+
}
|
|
2673
|
+
if (changes.associations) {
|
|
2674
|
+
const before = existing.associations ?? [];
|
|
2675
|
+
const after = changes.associations;
|
|
2676
|
+
if (after.some((a) => !before.some((b) => associationEqual(a, b))))
|
|
2677
|
+
ops.push('associate');
|
|
2678
|
+
if (before.some((b) => !after.some((a) => associationEqual(a, b))))
|
|
2679
|
+
ops.push('dissociate');
|
|
2680
|
+
}
|
|
2681
|
+
if (changes.unlisted !== undefined && Boolean(existing.unlistedAt) !== changes.unlisted) {
|
|
2682
|
+
ops.push(changes.unlisted ? 'unlist' : 'list');
|
|
2683
|
+
}
|
|
2684
|
+
return ops;
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* The change set narrowed to the aspects that actually moved. An adapter
|
|
2688
|
+
* is handed this rather than what the caller wrote, so restating an aspect
|
|
2689
|
+
* cannot rewrite it: `unlisted: true` on an already-unlisted record would
|
|
2690
|
+
* otherwise drag `unlistedAt` forward, moving the record's publish moment
|
|
2691
|
+
* with no op reporting it. It also keeps the adapter contract honest —
|
|
2692
|
+
* every key an adapter receives is one it must write.
|
|
2693
|
+
*/
|
|
2694
|
+
function effectiveChanges(changes, ops) {
|
|
2695
|
+
const effective = {};
|
|
2696
|
+
if (ops.includes('patch'))
|
|
2697
|
+
effective.contentPatch = changes.contentPatch;
|
|
2698
|
+
if (ops.includes('reparent'))
|
|
2699
|
+
effective.parentId = changes.parentId;
|
|
2700
|
+
if (ops.includes('permissions'))
|
|
2701
|
+
effective.permissions = changes.permissions;
|
|
2702
|
+
if (ops.includes('associate') || ops.includes('dissociate')) {
|
|
2703
|
+
effective.associations = changes.associations;
|
|
2704
|
+
}
|
|
2705
|
+
if (ops.includes('unlist') || ops.includes('list'))
|
|
2706
|
+
effective.unlisted = changes.unlisted;
|
|
2707
|
+
return effective;
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Whether a merge patch produced the content the record already held.
|
|
2711
|
+
* Compared by serialization: content is JSON by construction — it round
|
|
2712
|
+
* trips through storage that way — and a patch preserves key order for
|
|
2713
|
+
* every field it does not name, so the encoding of an unchanged record is
|
|
2714
|
+
* stable. A reordering patch that changes nothing else is the one case
|
|
2715
|
+
* this reports as a change, which costs an empty version rather than a
|
|
2716
|
+
* wrong answer.
|
|
2717
|
+
*/
|
|
2718
|
+
function contentEqual(a, b) {
|
|
2719
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
2720
|
+
}
|
|
2488
2721
|
function permissionsEqual(a, b) {
|
|
2489
2722
|
return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
|
|
2490
2723
|
}
|
|
@@ -2494,6 +2727,13 @@ function permissionsEqual(a, b) {
|
|
|
2494
2727
|
/** Default page size used to fill a permission-filtered query result. */
|
|
2495
2728
|
const DEFAULT_QUERY_LIMIT = 50;
|
|
2496
2729
|
const MAX_QUERY_LIMIT = 1000;
|
|
2730
|
+
/**
|
|
2731
|
+
* How far a change set's `parentId` will walk a proposed ancestor chain before refusing
|
|
2732
|
+
* the move. Bounds the reads one call can cost; a hierarchy deeper than
|
|
2733
|
+
* this is beyond what `parentId` is for. See docs/spec/data-model.md
|
|
2734
|
+
* § Reparenting.
|
|
2735
|
+
*/
|
|
2736
|
+
const MAX_PARENT_DEPTH = 64;
|
|
2497
2737
|
/**
|
|
2498
2738
|
* Ensures `creator` carries an `admin` relationship association, adding one
|
|
2499
2739
|
* if it's not already present. Used to bootstrap a `_group` record's first
|
|
@@ -2512,6 +2752,13 @@ function stampGroupAdmin(associations, creator) {
|
|
|
2512
2752
|
{ kind: 'relationship', label: 'admin', target: { scope: 'entity', entityId: creator } },
|
|
2513
2753
|
];
|
|
2514
2754
|
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Whether a Record is a `_group`, in any of its type versions — the family
|
|
2757
|
+
* whose roster rules apply.
|
|
2758
|
+
*/
|
|
2759
|
+
function isGroupRecord(record) {
|
|
2760
|
+
return baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP;
|
|
2761
|
+
}
|
|
2515
2762
|
/**
|
|
2516
2763
|
* Drops a snapshot's `permissions` — owner-only audit data, never served
|
|
2517
2764
|
* to a non-owner history reader. `entityId` (change attribution) stays.
|
|
@@ -2554,7 +2801,7 @@ const presentDeleted = (record) => record.deletedAt ? tombstoneOf(record) : reco
|
|
|
2554
2801
|
*
|
|
2555
2802
|
* Two identities, one rule: **the principal governs authority, the subject
|
|
2556
2803
|
* governs attribution.** Grant lookup and the privilege-bearing gates that
|
|
2557
|
-
* no grant reaches (
|
|
2804
|
+
* no grant reaches (resharing, group management, hard delete, widening
|
|
2558
2805
|
* access at create time) key on `principalEntityId`; authorship, `-own`
|
|
2559
2806
|
* matching, record-level permission resolution, and "files I uploaded"
|
|
2560
2807
|
* lookups key on `subjectEntityId`.
|
|
@@ -2897,7 +3144,7 @@ export class ScopedStack {
|
|
|
2897
3144
|
* `_group` records are managed, not merely written: only the owner or an
|
|
2898
3145
|
* `admin` roster holder may mutate them — ordinary write permissions and
|
|
2899
3146
|
* grants don't apply. Asked of both identities under delegation, like
|
|
2900
|
-
*
|
|
3147
|
+
* a reshare. See docs/spec/identity.md § Group.
|
|
2901
3148
|
*/
|
|
2902
3149
|
isGroupManager(record) {
|
|
2903
3150
|
if (!this.managesGroup(this.principalEntityId, record))
|
|
@@ -3059,7 +3306,7 @@ export class ScopedStack {
|
|
|
3059
3306
|
* Gates file-ref content fields on file access, mirroring the
|
|
3060
3307
|
* attachment-association gate — a file-ref field conveys attachment
|
|
3061
3308
|
* access exactly like an `attachment` association. Only fields present
|
|
3062
|
-
* in `content` are checked (
|
|
3309
|
+
* in `content` are checked (a content patch is a merge patch; untouched fields
|
|
3063
3310
|
* carry no new reference).
|
|
3064
3311
|
*/
|
|
3065
3312
|
async requireFileRefAccess(typeId, content) {
|
|
@@ -3178,7 +3425,7 @@ export class ScopedStack {
|
|
|
3178
3425
|
}
|
|
3179
3426
|
/**
|
|
3180
3427
|
* Whether this request may decide who else reaches a record — the rule
|
|
3181
|
-
*
|
|
3428
|
+
* a reshare enforces, asked at create time too so the reach it
|
|
3182
3429
|
* withholds can't be taken one step earlier while authoring. A delegated
|
|
3183
3430
|
* app is denied it: widening access is the one thing containment most
|
|
3184
3431
|
* needs to hold. Refused rather than silently ignored, so an app never
|
|
@@ -3192,7 +3439,7 @@ export class ScopedStack {
|
|
|
3192
3439
|
}
|
|
3193
3440
|
/**
|
|
3194
3441
|
* Whether one identity, on its own, may decide who else reaches `record`
|
|
3195
|
-
* — the owner-or-creator rule
|
|
3442
|
+
* — the owner-or-creator rule a reshare enforces, asked of one
|
|
3196
3443
|
* side at a time. See
|
|
3197
3444
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
3198
3445
|
*/
|
|
@@ -3268,28 +3515,105 @@ export class ScopedStack {
|
|
|
3268
3515
|
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
3269
3516
|
return { records, cursor: page.cursor };
|
|
3270
3517
|
}
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3284
|
-
//
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3292
|
-
|
|
3518
|
+
/**
|
|
3519
|
+
* Apply a change set on behalf of the subject. Authority is resolved
|
|
3520
|
+
* **per key** and every gate reads the record as it stands, never as the
|
|
3521
|
+
* change set would leave it: a widened `permissions` never satisfies the
|
|
3522
|
+
* read check on a `parentId` named in the same call, and a `_group`
|
|
3523
|
+
* roster never satisfies the admin check that same call has to pass.
|
|
3524
|
+
* One refused key refuses the whole call, so nothing is partially
|
|
3525
|
+
* applied and no key is silently dropped.
|
|
3526
|
+
* See docs/spec/access-control.md § Composing a change set.
|
|
3527
|
+
*/
|
|
3528
|
+
async mutate(id, changes, opts = {}) {
|
|
3529
|
+
// Ahead of every gate: a malformed change set is a validation error
|
|
3530
|
+
// for every requester, rather than one for the owner and a permission
|
|
3531
|
+
// refusal for everyone else.
|
|
3532
|
+
assertNonEmptyChangeSet(changes);
|
|
3533
|
+
if (changes.contentPatch) {
|
|
3534
|
+
const patchErrors = validatePatchValues(changes.contentPatch);
|
|
3535
|
+
if (patchErrors.length > 0)
|
|
3536
|
+
throw new StackValidationError(patchErrors);
|
|
3537
|
+
}
|
|
3538
|
+
const reshares = changes.permissions !== undefined || changes.unlisted !== undefined;
|
|
3539
|
+
const writes = changes.contentPatch !== undefined ||
|
|
3540
|
+
changes.associations !== undefined ||
|
|
3541
|
+
changes.parentId !== undefined;
|
|
3542
|
+
// requireUpdatable() reads the record and applies the write gate; the
|
|
3543
|
+
// reshare keys need the record before their own gate, and a change set
|
|
3544
|
+
// carrying only those must not be held to the write gate it doesn't
|
|
3545
|
+
// need. One read either way.
|
|
3546
|
+
const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
|
|
3547
|
+
if (writes && reshares)
|
|
3548
|
+
await this.requireReshareOf(record);
|
|
3549
|
+
if (changes.contentPatch) {
|
|
3550
|
+
const patch = changes.contentPatch;
|
|
3551
|
+
// Value-wise, not presence-wise: a client that reads a card, edits
|
|
3552
|
+
// its `name` and sends the whole content object back is not setting
|
|
3553
|
+
// the binding it round-trips, and `name` is writable by record-level
|
|
3554
|
+
// permission. Same predicate restoreVersion() applies to a snapshot.
|
|
3555
|
+
this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
|
|
3556
|
+
// Likewise value-wise: re-sending the DID a card already holds
|
|
3557
|
+
// claims nothing, and immutability refuses changing it regardless.
|
|
3558
|
+
if ('did' in patch && patch.did !== record.content.did) {
|
|
3559
|
+
this.requireOwnerForOwnerDid(record.typeId, patch.did);
|
|
3560
|
+
}
|
|
3561
|
+
await this.requireFileRefAccess(record.typeId, patch);
|
|
3562
|
+
}
|
|
3563
|
+
// Every association the change set would add is a reference this
|
|
3564
|
+
// requester has to be allowed to create — asked against the stored
|
|
3565
|
+
// set, so an association already on the record is not re-gated.
|
|
3566
|
+
for (const assoc of changes.associations ?? []) {
|
|
3567
|
+
if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
|
|
3568
|
+
continue;
|
|
3569
|
+
await this.requireAssociationAccess(record.typeId, assoc);
|
|
3570
|
+
}
|
|
3571
|
+
if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
|
|
3572
|
+
throw new StackPermissionError();
|
|
3573
|
+
}
|
|
3574
|
+
return this.stack.mutate(id, changes, { ...opts, ...this.actor });
|
|
3575
|
+
}
|
|
3576
|
+
async patchContent(id, patch, opts = {}) {
|
|
3577
|
+
return this.mutate(id, { contentPatch: patch }, opts);
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* The record, having established that this request may decide who else
|
|
3581
|
+
* reaches it — the owner-or-creator rule, asked of both sides of a
|
|
3582
|
+
* delegation, or a Group's admin rule for a `_group`. The gate the
|
|
3583
|
+
* `permissions` and `unlisted` keys carry, which the write bit
|
|
3584
|
+
* deliberately does not confer.
|
|
3585
|
+
*/
|
|
3586
|
+
async requireReshareable(id) {
|
|
3587
|
+
const record = await this.stack.get(id);
|
|
3588
|
+
if (!record)
|
|
3589
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3590
|
+
await this.requireOwnerForGrantRecord(record);
|
|
3591
|
+
await this.requireReshareOf(record);
|
|
3592
|
+
// Reached through its own gate rather than requireUpdatable(), so the
|
|
3593
|
+
// soft-delete refusal has to be asked here too — after the authority
|
|
3594
|
+
// decision above, for the reason refuseIfDeleted() gives.
|
|
3595
|
+
return this.refuseIfDeleted(record, true);
|
|
3596
|
+
}
|
|
3597
|
+
/** The reshare decision alone, for a record already read and write-gated. */
|
|
3598
|
+
async requireReshareOf(record) {
|
|
3599
|
+
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3600
|
+
// Group management, not authorship: a creator later demoted from the
|
|
3601
|
+
// admin roster shouldn't retain a side door to reassign who can read
|
|
3602
|
+
// or write the group record.
|
|
3603
|
+
if (!this.isGroupManager(record))
|
|
3604
|
+
throw await this.denialFor(record);
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
// Intersected like every other authority here: the principal must hold
|
|
3608
|
+
// the verb, and the subject must be able to reach this record —
|
|
3609
|
+
// without which an owner principal would carry its subject to records
|
|
3610
|
+
// the subject cannot touch. create() withholds the same reach via
|
|
3611
|
+
// mayGrantAccess().
|
|
3612
|
+
if (!this.mayReshare(this.principalEntityId, record))
|
|
3613
|
+
throw await this.denialFor(record);
|
|
3614
|
+
if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
|
|
3615
|
+
throw await this.denialFor(record);
|
|
3616
|
+
}
|
|
3293
3617
|
}
|
|
3294
3618
|
/**
|
|
3295
3619
|
* Naming the software behind a key is the trust decision the `_app`
|
|
@@ -3388,62 +3712,6 @@ export class ScopedStack {
|
|
|
3388
3712
|
await this.requireUpdatable(id);
|
|
3389
3713
|
return this.stack.dissociate(id, association, { ...opts, ...this.actor });
|
|
3390
3714
|
}
|
|
3391
|
-
async setPermissions(id, permissions, opts = {}) {
|
|
3392
|
-
const record = await this.stack.get(id);
|
|
3393
|
-
if (!record)
|
|
3394
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3395
|
-
await this.requireOwnerForGrantRecord(record);
|
|
3396
|
-
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3397
|
-
// Group management, not authorship: a creator later demoted from the
|
|
3398
|
-
// admin roster shouldn't retain a side door to reassign who can read
|
|
3399
|
-
// or write the group record. Same gate as update/associate/delete.
|
|
3400
|
-
if (!this.isGroupManager(record))
|
|
3401
|
-
throw await this.denialFor(record);
|
|
3402
|
-
}
|
|
3403
|
-
else {
|
|
3404
|
-
// Intersected like every other authority here: the principal must
|
|
3405
|
-
// hold the verb, and the subject must be able to reach this record —
|
|
3406
|
-
// without which an owner principal would carry its subject to records
|
|
3407
|
-
// the subject cannot touch. create() withholds the same reach via
|
|
3408
|
-
// mayGrantAccess().
|
|
3409
|
-
if (!this.mayReshare(this.principalEntityId, record))
|
|
3410
|
-
throw await this.denialFor(record);
|
|
3411
|
-
if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
|
|
3412
|
-
throw await this.denialFor(record);
|
|
3413
|
-
}
|
|
3414
|
-
}
|
|
3415
|
-
// Reached through its own gate rather than requireUpdatable(), so the
|
|
3416
|
-
// soft-delete refusal has to be asked here too — after the authority
|
|
3417
|
-
// decision above, for the reason refuseIfDeleted() gives.
|
|
3418
|
-
this.refuseIfDeleted(record, true);
|
|
3419
|
-
return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
|
|
3420
|
-
}
|
|
3421
|
-
/**
|
|
3422
|
-
* Withhold a record from enumeration, or restore it — gated exactly like
|
|
3423
|
-
* setPermissions(), since both decide who or what can discover the
|
|
3424
|
-
* record rather than merely read it once found. See
|
|
3425
|
-
* docs/spec/unlisted.md.
|
|
3426
|
-
*/
|
|
3427
|
-
async setUnlisted(id, unlisted, opts = {}) {
|
|
3428
|
-
const record = await this.stack.get(id);
|
|
3429
|
-
if (!record)
|
|
3430
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3431
|
-
await this.requireOwnerForGrantRecord(record);
|
|
3432
|
-
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3433
|
-
if (!this.isGroupManager(record))
|
|
3434
|
-
throw await this.denialFor(record);
|
|
3435
|
-
}
|
|
3436
|
-
else {
|
|
3437
|
-
if (!this.mayReshare(this.principalEntityId, record))
|
|
3438
|
-
throw await this.denialFor(record);
|
|
3439
|
-
if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
|
|
3440
|
-
throw await this.denialFor(record);
|
|
3441
|
-
}
|
|
3442
|
-
}
|
|
3443
|
-
// See setPermissions() above — same gate, same reason.
|
|
3444
|
-
this.refuseIfDeleted(record, true);
|
|
3445
|
-
return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
|
|
3446
|
-
}
|
|
3447
3715
|
/**
|
|
3448
3716
|
* Hard delete is owner-only: it is irreversible and destroys version
|
|
3449
3717
|
* history, so neither the write bit nor delete-own/delete-any grants
|
|
@@ -3468,7 +3736,7 @@ export class ScopedStack {
|
|
|
3468
3736
|
}
|
|
3469
3737
|
/**
|
|
3470
3738
|
* History is the mutation/recovery surface, not a read surface — gated
|
|
3471
|
-
* like
|
|
3739
|
+
* like patchContent(), with snapshot `permissions` stripped for everyone but
|
|
3472
3740
|
* the owner acting alone — a snapshot's permissions are the stack's
|
|
3473
3741
|
* sharing graph, which delegation is not a route to. Reading history
|
|
3474
3742
|
* changes nothing, so it is the one path the `_grant` write fence leaves
|
|
@@ -3493,9 +3761,11 @@ export class ScopedStack {
|
|
|
3493
3761
|
/**
|
|
3494
3762
|
* Re-runs the reference-creation checks against the snapshot, so a
|
|
3495
3763
|
* restore can't re-convey access to a file or record the subject can no
|
|
3496
|
-
* longer reach today
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
3764
|
+
* longer reach today — the snapshot's `parentId` among them, gated
|
|
3765
|
+
* exactly as a change set's `parentId` gates a destination named directly. Only the
|
|
3766
|
+
* owner acting alone is exempt: under delegation the checks resolve
|
|
3767
|
+
* against the subject, which is whose reach the restore would widen.
|
|
3768
|
+
* See docs/spec/versioning.md § Restore semantics.
|
|
3499
3769
|
*/
|
|
3500
3770
|
async restoreVersion(id, version, opts = {}) {
|
|
3501
3771
|
const record = await this.requireUpdatable(id);
|
|
@@ -3503,12 +3773,29 @@ export class ScopedStack {
|
|
|
3503
3773
|
const target = await this.stack.getVersion(id, version);
|
|
3504
3774
|
if (target) {
|
|
3505
3775
|
// A rollback that would move a card's binding is the same trust
|
|
3506
|
-
// decision
|
|
3776
|
+
// decision a content patch reserves to the owner, reached by another route.
|
|
3507
3777
|
this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
|
|
3508
3778
|
record.content[field]);
|
|
3779
|
+
// Only a restore that moves the record *into* a container creates a
|
|
3780
|
+
// reference. A snapshot at the root names nothing, and a parentId
|
|
3781
|
+
// the restore would not change is not re-gated: the record is
|
|
3782
|
+
// already there, so a content rollback is not refused over a move
|
|
3783
|
+
// it isn't making.
|
|
3784
|
+
if (target.parentId !== undefined &&
|
|
3785
|
+
target.parentId !== record.parentId &&
|
|
3786
|
+
!(await this.canReadReferent(target.parentId))) {
|
|
3787
|
+
throw new StackPermissionError();
|
|
3788
|
+
}
|
|
3509
3789
|
await this.requireFileRefAccess(target.typeId, target.content);
|
|
3510
|
-
|
|
3511
|
-
|
|
3790
|
+
// Gated against the associations the restore will actually write.
|
|
3791
|
+
// A `_group`'s don't roll back at all, so such a restore introduces
|
|
3792
|
+
// no association and there is nothing here to gate — the same
|
|
3793
|
+
// reason a `parentId` the restore would not change is not re-gated
|
|
3794
|
+
// above.
|
|
3795
|
+
if (!isGroupRecord(record)) {
|
|
3796
|
+
for (const association of target.associations ?? []) {
|
|
3797
|
+
await this.requireAssociationAccess(target.typeId, association);
|
|
3798
|
+
}
|
|
3512
3799
|
}
|
|
3513
3800
|
}
|
|
3514
3801
|
}
|
|
@@ -3526,7 +3813,7 @@ export class ScopedStack {
|
|
|
3526
3813
|
* The restriction is what makes the verb safe to expose at all. Migrate
|
|
3527
3814
|
* replaces `content` and `typeId` wholesale, so a grant-based version
|
|
3528
3815
|
* would have to re-derive every gate `create()` applies at the
|
|
3529
|
-
* destination *and* every gate `
|
|
3816
|
+
* destination *and* every gate `mutate()` applies over the existing
|
|
3530
3817
|
* content, and would reopen each one it missed. The sharpest is the
|
|
3531
3818
|
* non-owner `_attachment@1` refusal create() carries: without it, a
|
|
3532
3819
|
* requester holding a create grant on `_attachment@1` and write access
|