@haverstack/core 0.28.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 +1 -2
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +27 -6
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +1 -4
- 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 +104 -90
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +361 -260
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +3 -14
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +27 -41
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +98 -28
- 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 +1 -1
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
|
|
@@ -854,7 +854,7 @@ export class Stack {
|
|
|
854
854
|
/**
|
|
855
855
|
* Announce a mutation that has already been persisted. Called after the
|
|
856
856
|
* adapter write resolves and before the mutating method settles, so
|
|
857
|
-
* `await stack.
|
|
857
|
+
* `await stack.mutate(...)` guarantees subscribers have been notified —
|
|
858
858
|
* and nothing about work they deferred. A handler cannot fail the write:
|
|
859
859
|
* there is nothing left to fail. See docs/spec/events.md § Handlers.
|
|
860
860
|
*/
|
|
@@ -984,7 +984,17 @@ export class Stack {
|
|
|
984
984
|
const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
|
|
985
985
|
if (parsed.version > priorMax)
|
|
986
986
|
this.maxDefinedVersion.set(parsed.baseId, parsed.version);
|
|
987
|
-
|
|
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
|
+
];
|
|
988
998
|
if (nameErrors.length > 0) {
|
|
989
999
|
throw new StackValidationError(nameErrors);
|
|
990
1000
|
}
|
|
@@ -1314,64 +1324,140 @@ export class Stack {
|
|
|
1314
1324
|
return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
|
|
1315
1325
|
}
|
|
1316
1326
|
/**
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
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.
|
|
1322
1341
|
*/
|
|
1323
|
-
async
|
|
1342
|
+
async mutate(id, changes, opts = {}) {
|
|
1324
1343
|
this.assertOpen();
|
|
1344
|
+
assertNonEmptyChangeSet(changes);
|
|
1325
1345
|
const existing = await this.adapter.getRecord(id);
|
|
1326
1346
|
if (!existing) {
|
|
1327
1347
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1328
1348
|
}
|
|
1329
1349
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1330
|
-
const
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
const
|
|
1340
|
-
...validateReservedKeys(content),
|
|
1341
|
-
...validatePatchValues(content),
|
|
1342
|
-
...validateContentKeys(content),
|
|
1343
|
-
];
|
|
1344
|
-
if (patchErrors.length > 0) {
|
|
1345
|
-
throw new StackValidationError(patchErrors);
|
|
1346
|
-
}
|
|
1347
|
-
// The patch is what travels, so the patch is what's measured — a small
|
|
1348
|
-
// patch against a large record is not an oversized request.
|
|
1349
|
-
assertContentSize(content, this.features.limits.contentBytes, 'Patch');
|
|
1350
|
-
// Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
|
|
1351
|
-
const merged = applyMergePatch(existing.content, content);
|
|
1352
|
-
const errors = validateContent(merged, type.schema);
|
|
1353
|
-
if (errors.length > 0) {
|
|
1354
|
-
throw new StackValidationError(errors);
|
|
1355
|
-
}
|
|
1356
|
-
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
1357
|
-
this.checkAttachmentImmutableFields(content, existing.content, merged);
|
|
1358
|
-
}
|
|
1359
|
-
await this.checkBindingsOnUpdate(existing.typeId, id, content, existing.content, merged);
|
|
1360
|
-
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1361
|
-
this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
1362
|
-
}
|
|
1363
|
-
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), {
|
|
1364
1360
|
expectedVersion: opts.ifVersion,
|
|
1365
1361
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1366
1362
|
updatedBy: opts.updatedBy,
|
|
1367
1363
|
updatedVia: opts.updatedVia,
|
|
1368
1364
|
});
|
|
1369
|
-
this.emitChange(
|
|
1365
|
+
this.emitChange(ops, updated, ops.includes('reparent') ? { previousParentId } : {});
|
|
1370
1366
|
return updated;
|
|
1371
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
|
+
}
|
|
1372
1458
|
/**
|
|
1373
1459
|
* Add an association to a record. Snapshots the record's prior state and
|
|
1374
|
-
* bumps version, same as
|
|
1460
|
+
* bumps version, same as patchContent() — associations are covered by the same
|
|
1375
1461
|
* versioning rule as content.
|
|
1376
1462
|
* If the association already exists (same kind, label, and payload), this
|
|
1377
1463
|
* is a no-op. Returns the record as it now stands — unchanged on a no-op.
|
|
@@ -1416,6 +1502,13 @@ export class Stack {
|
|
|
1416
1502
|
if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) {
|
|
1417
1503
|
return existing;
|
|
1418
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
|
+
}
|
|
1419
1512
|
const updated = await this.adapter.dissociate(id, association, {
|
|
1420
1513
|
expectedVersion: opts.ifVersion,
|
|
1421
1514
|
snapshot: this.buildVersionSnapshot(existing),
|
|
@@ -1425,104 +1518,10 @@ export class Stack {
|
|
|
1425
1518
|
this.emitChange('dissociate', updated);
|
|
1426
1519
|
return updated;
|
|
1427
1520
|
}
|
|
1428
|
-
/**
|
|
1429
|
-
* Replace all permissions on a record. Snapshots and bumps version, same
|
|
1430
|
-
* as associate(). Pass an empty array to make the record private (the
|
|
1431
|
-
* default). No-op if the new set is deep-equal to the current one. Returns
|
|
1432
|
-
* the record as it now stands — unchanged on a no-op.
|
|
1433
|
-
*/
|
|
1434
|
-
async setPermissions(id, permissions, opts = {}) {
|
|
1435
|
-
this.assertOpen();
|
|
1436
|
-
const errors = validatePermissions(permissions);
|
|
1437
|
-
if (errors.length > 0) {
|
|
1438
|
-
throw new StackValidationError(errors);
|
|
1439
|
-
}
|
|
1440
|
-
const existing = await this.adapter.getRecord(id);
|
|
1441
|
-
if (!existing) {
|
|
1442
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1443
|
-
}
|
|
1444
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1445
|
-
if (permissionsEqual(existing.permissions ?? [], permissions))
|
|
1446
|
-
return existing;
|
|
1447
|
-
const updated = await this.adapter.setPermissions(id, permissions, {
|
|
1448
|
-
expectedVersion: opts.ifVersion,
|
|
1449
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1450
|
-
updatedBy: opts.updatedBy,
|
|
1451
|
-
updatedVia: opts.updatedVia,
|
|
1452
|
-
});
|
|
1453
|
-
this.emitChange('permissions', updated);
|
|
1454
|
-
return updated;
|
|
1455
|
-
}
|
|
1456
|
-
/**
|
|
1457
|
-
* Withhold a record from enumeration, or restore it. Orthogonal to
|
|
1458
|
-
* setPermissions(): it says nothing about who may read the record, only
|
|
1459
|
-
* whether `query()` and the change feed enumerate it by default. No-op if
|
|
1460
|
-
* already in the requested state. Returns the record as it now stands —
|
|
1461
|
-
* unchanged on a no-op. See docs/spec/access-control.md §
|
|
1462
|
-
* Unlisted records.
|
|
1463
|
-
*
|
|
1464
|
-
* The op passed to emitChange() carries the transition direction —
|
|
1465
|
-
* `unlist` (kind `deleted`, so subscribers already holding the record are
|
|
1466
|
-
* told to drop it) or `list` (kind `changed`, an upsert like `undelete`,
|
|
1467
|
-
* for the record's publish moment).
|
|
1468
|
-
*/
|
|
1469
|
-
async setUnlisted(id, unlisted, opts = {}) {
|
|
1470
|
-
this.assertOpen();
|
|
1471
|
-
const existing = await this.adapter.getRecord(id);
|
|
1472
|
-
if (!existing) {
|
|
1473
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1474
|
-
}
|
|
1475
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1476
|
-
if (Boolean(existing.unlistedAt) === unlisted)
|
|
1477
|
-
return existing;
|
|
1478
|
-
const updated = await this.adapter.setUnlisted(id, unlisted, {
|
|
1479
|
-
expectedVersion: opts.ifVersion,
|
|
1480
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1481
|
-
updatedBy: opts.updatedBy,
|
|
1482
|
-
updatedVia: opts.updatedVia,
|
|
1483
|
-
});
|
|
1484
|
-
this.emitChange(unlisted ? 'unlist' : 'list', updated);
|
|
1485
|
-
return updated;
|
|
1486
|
-
}
|
|
1487
|
-
/**
|
|
1488
|
-
* Move a record into another container, or to the root with `null`.
|
|
1489
|
-
* Snapshots and bumps version, same as setUnlisted(). No-op if the
|
|
1490
|
-
* record is already there. Returns the record as it now stands —
|
|
1491
|
-
* unchanged on a no-op.
|
|
1492
|
-
*
|
|
1493
|
-
* `parentId` is the one native field a write-holder may move after
|
|
1494
|
-
* creation, and it confers nothing: containment is not an
|
|
1495
|
-
* access-control edge, so a move changes which queries and feeds
|
|
1496
|
-
* enumerate a record and nothing about who may read it. See
|
|
1497
|
-
* docs/spec/data-model.md § Reparenting.
|
|
1498
|
-
*/
|
|
1499
|
-
async setParent(id, parentId, opts = {}) {
|
|
1500
|
-
this.assertOpen();
|
|
1501
|
-
const existing = await this.adapter.getRecord(id);
|
|
1502
|
-
if (!existing) {
|
|
1503
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1504
|
-
}
|
|
1505
|
-
this.checkIfVersion(existing, opts.ifVersion);
|
|
1506
|
-
const previousParentId = existing.parentId ?? null;
|
|
1507
|
-
if (previousParentId === parentId)
|
|
1508
|
-
return existing;
|
|
1509
|
-
if (parentId !== null) {
|
|
1510
|
-
await this.assertParentExists(id, parentId);
|
|
1511
|
-
await this.assertNoParentCycle(id, parentId);
|
|
1512
|
-
}
|
|
1513
|
-
const updated = await this.adapter.setParent(id, parentId, {
|
|
1514
|
-
expectedVersion: opts.ifVersion,
|
|
1515
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1516
|
-
updatedBy: opts.updatedBy,
|
|
1517
|
-
updatedVia: opts.updatedVia,
|
|
1518
|
-
});
|
|
1519
|
-
this.emitChange('reparent', updated, { previousParentId });
|
|
1520
|
-
return updated;
|
|
1521
|
-
}
|
|
1522
1521
|
/**
|
|
1523
1522
|
* Refuse an edge that would make a record its own ancestor — asked at
|
|
1524
|
-
* every site that adds one:
|
|
1525
|
-
* back, and a create naming both its own id and a parent. Nothing
|
|
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
|
|
1526
1525
|
* downstream (a generator deriving a page path, a folder view) is
|
|
1527
1526
|
* written to survive a cycle.
|
|
1528
1527
|
*
|
|
@@ -1728,7 +1727,7 @@ export class Stack {
|
|
|
1728
1727
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
|
|
1729
1728
|
}
|
|
1730
1729
|
// Restoring is a write like any other, so it owes the same immutability
|
|
1731
|
-
// check
|
|
1730
|
+
// check a content patch pays — a snapshot taken before a card claimed its DID
|
|
1732
1731
|
// would otherwise move the binding by rolling content back. Uniqueness
|
|
1733
1732
|
// needs no separate check here: a restore can only put back a value this
|
|
1734
1733
|
// same card already held, which immutability already refuses to change.
|
|
@@ -1736,7 +1735,7 @@ export class Stack {
|
|
|
1736
1735
|
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
1737
1736
|
}
|
|
1738
1737
|
// A restore that puts a container back is an edge-adding site like
|
|
1739
|
-
//
|
|
1738
|
+
// a change set's `parentId`, and the chain above that container may have moved since
|
|
1740
1739
|
// the snapshot was taken. The cycle walk therefore applies — but
|
|
1741
1740
|
// assertParentExists() deliberately does not: a restore is not a caller
|
|
1742
1741
|
// naming a destination, it is history being put back, and the container
|
|
@@ -1751,11 +1750,14 @@ export class Stack {
|
|
|
1751
1750
|
if (moves && targetParentId !== null) {
|
|
1752
1751
|
await this.assertNoParentCycle(id, targetParentId);
|
|
1753
1752
|
}
|
|
1753
|
+
// A `_group`'s roster is authority, not data: it does not roll back.
|
|
1754
|
+
// See docs/spec/versioning.md § Restore semantics.
|
|
1754
1755
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1755
1756
|
expectedVersion: opts.ifVersion,
|
|
1756
1757
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1757
1758
|
updatedBy: opts.updatedBy,
|
|
1758
1759
|
updatedVia: opts.updatedVia,
|
|
1760
|
+
...(isGroupRecord(existing) && { restoreAssociations: false }),
|
|
1759
1761
|
});
|
|
1760
1762
|
this.emitChange('restore', restored, moves ? { previousParentId } : {});
|
|
1761
1763
|
return restored;
|
|
@@ -1763,12 +1765,12 @@ export class Stack {
|
|
|
1763
1765
|
/**
|
|
1764
1766
|
* Commit a per-record migration: replace `content` and `typeId` together
|
|
1765
1767
|
* in one step, validated against `toTypeId`'s schema exactly as
|
|
1766
|
-
* create()/
|
|
1768
|
+
* create()/mutate() validate against a type's schema. The single-record
|
|
1767
1769
|
* counterpart to migrateAll() — content here is supplied by the caller
|
|
1768
1770
|
* (computed client-side by the type's owning app, per
|
|
1769
1771
|
* docs/spec/wire-format.md § Migration commit) rather than a registered
|
|
1770
1772
|
* Migration function. Snapshots the prior state to version history, same
|
|
1771
|
-
* as
|
|
1773
|
+
* as mutate()/restoreVersion(), and takes the same optional `ifVersion`
|
|
1772
1774
|
* precondition every version-bumping mutation takes — checked atomically
|
|
1773
1775
|
* at the adapter, not here (see docs/spec/versioning.md § Optimistic
|
|
1774
1776
|
* concurrency).
|
|
@@ -1778,7 +1780,7 @@ export class Stack {
|
|
|
1778
1780
|
* record as it stands, so it owes both sets of integrity checks — the
|
|
1779
1781
|
* binding rules, the attachment rules, and `_config`'s. Missing either
|
|
1780
1782
|
* half would make migrate a second, unguarded write path to the same
|
|
1781
|
-
* state create()/
|
|
1783
|
+
* state create()/mutate() refuse to reach.
|
|
1782
1784
|
*/
|
|
1783
1785
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1784
1786
|
this.assertOpen();
|
|
@@ -1864,7 +1866,7 @@ export class Stack {
|
|
|
1864
1866
|
/**
|
|
1865
1867
|
* Immutability for every binding field a patch touches, then uniqueness
|
|
1866
1868
|
* for the subset that carries it. Fields absent from the patch carry no
|
|
1867
|
-
* new claim —
|
|
1869
|
+
* new claim — a content patch is a merge, so an untouched binding is the one the
|
|
1868
1870
|
* card already holds.
|
|
1869
1871
|
*/
|
|
1870
1872
|
async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
|
|
@@ -2055,7 +2057,7 @@ export class Stack {
|
|
|
2055
2057
|
}
|
|
2056
2058
|
}
|
|
2057
2059
|
/**
|
|
2058
|
-
* `_config.entityId` defines stack ownership; neither
|
|
2060
|
+
* `_config.entityId` defines stack ownership; neither patchContent() nor
|
|
2059
2061
|
* restoreVersion() may change it. A conflict with stack integrity, not a
|
|
2060
2062
|
* schema violation — hence StackConflictError. See docs/spec.md § The
|
|
2061
2063
|
* `_config` record.
|
|
@@ -2633,6 +2635,89 @@ function permissionEqual(a, b) {
|
|
|
2633
2635
|
}
|
|
2634
2636
|
return false;
|
|
2635
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
|
+
}
|
|
2636
2721
|
function permissionsEqual(a, b) {
|
|
2637
2722
|
return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
|
|
2638
2723
|
}
|
|
@@ -2643,7 +2728,7 @@ function permissionsEqual(a, b) {
|
|
|
2643
2728
|
const DEFAULT_QUERY_LIMIT = 50;
|
|
2644
2729
|
const MAX_QUERY_LIMIT = 1000;
|
|
2645
2730
|
/**
|
|
2646
|
-
* How far
|
|
2731
|
+
* How far a change set's `parentId` will walk a proposed ancestor chain before refusing
|
|
2647
2732
|
* the move. Bounds the reads one call can cost; a hierarchy deeper than
|
|
2648
2733
|
* this is beyond what `parentId` is for. See docs/spec/data-model.md
|
|
2649
2734
|
* § Reparenting.
|
|
@@ -2667,6 +2752,13 @@ function stampGroupAdmin(associations, creator) {
|
|
|
2667
2752
|
{ kind: 'relationship', label: 'admin', target: { scope: 'entity', entityId: creator } },
|
|
2668
2753
|
];
|
|
2669
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
|
+
}
|
|
2670
2762
|
/**
|
|
2671
2763
|
* Drops a snapshot's `permissions` — owner-only audit data, never served
|
|
2672
2764
|
* to a non-owner history reader. `entityId` (change attribution) stays.
|
|
@@ -2709,7 +2801,7 @@ const presentDeleted = (record) => record.deletedAt ? tombstoneOf(record) : reco
|
|
|
2709
2801
|
*
|
|
2710
2802
|
* Two identities, one rule: **the principal governs authority, the subject
|
|
2711
2803
|
* governs attribution.** Grant lookup and the privilege-bearing gates that
|
|
2712
|
-
* no grant reaches (
|
|
2804
|
+
* no grant reaches (resharing, group management, hard delete, widening
|
|
2713
2805
|
* access at create time) key on `principalEntityId`; authorship, `-own`
|
|
2714
2806
|
* matching, record-level permission resolution, and "files I uploaded"
|
|
2715
2807
|
* lookups key on `subjectEntityId`.
|
|
@@ -3052,7 +3144,7 @@ export class ScopedStack {
|
|
|
3052
3144
|
* `_group` records are managed, not merely written: only the owner or an
|
|
3053
3145
|
* `admin` roster holder may mutate them — ordinary write permissions and
|
|
3054
3146
|
* grants don't apply. Asked of both identities under delegation, like
|
|
3055
|
-
*
|
|
3147
|
+
* a reshare. See docs/spec/identity.md § Group.
|
|
3056
3148
|
*/
|
|
3057
3149
|
isGroupManager(record) {
|
|
3058
3150
|
if (!this.managesGroup(this.principalEntityId, record))
|
|
@@ -3214,7 +3306,7 @@ export class ScopedStack {
|
|
|
3214
3306
|
* Gates file-ref content fields on file access, mirroring the
|
|
3215
3307
|
* attachment-association gate — a file-ref field conveys attachment
|
|
3216
3308
|
* access exactly like an `attachment` association. Only fields present
|
|
3217
|
-
* in `content` are checked (
|
|
3309
|
+
* in `content` are checked (a content patch is a merge patch; untouched fields
|
|
3218
3310
|
* carry no new reference).
|
|
3219
3311
|
*/
|
|
3220
3312
|
async requireFileRefAccess(typeId, content) {
|
|
@@ -3333,7 +3425,7 @@ export class ScopedStack {
|
|
|
3333
3425
|
}
|
|
3334
3426
|
/**
|
|
3335
3427
|
* Whether this request may decide who else reaches a record — the rule
|
|
3336
|
-
*
|
|
3428
|
+
* a reshare enforces, asked at create time too so the reach it
|
|
3337
3429
|
* withholds can't be taken one step earlier while authoring. A delegated
|
|
3338
3430
|
* app is denied it: widening access is the one thing containment most
|
|
3339
3431
|
* needs to hold. Refused rather than silently ignored, so an app never
|
|
@@ -3347,7 +3439,7 @@ export class ScopedStack {
|
|
|
3347
3439
|
}
|
|
3348
3440
|
/**
|
|
3349
3441
|
* Whether one identity, on its own, may decide who else reaches `record`
|
|
3350
|
-
* — the owner-or-creator rule
|
|
3442
|
+
* — the owner-or-creator rule a reshare enforces, asked of one
|
|
3351
3443
|
* side at a time. See
|
|
3352
3444
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
3353
3445
|
*/
|
|
@@ -3423,28 +3515,105 @@ export class ScopedStack {
|
|
|
3423
3515
|
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
3424
3516
|
return { records, cursor: page.cursor };
|
|
3425
3517
|
}
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
//
|
|
3438
|
-
//
|
|
3439
|
-
//
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
}
|
|
3446
|
-
|
|
3447
|
-
|
|
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
|
+
}
|
|
3448
3617
|
}
|
|
3449
3618
|
/**
|
|
3450
3619
|
* Naming the software behind a key is the trust decision the `_app`
|
|
@@ -3543,81 +3712,6 @@ export class ScopedStack {
|
|
|
3543
3712
|
await this.requireUpdatable(id);
|
|
3544
3713
|
return this.stack.dissociate(id, association, { ...opts, ...this.actor });
|
|
3545
3714
|
}
|
|
3546
|
-
async setPermissions(id, permissions, opts = {}) {
|
|
3547
|
-
const record = await this.stack.get(id);
|
|
3548
|
-
if (!record)
|
|
3549
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3550
|
-
await this.requireOwnerForGrantRecord(record);
|
|
3551
|
-
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3552
|
-
// Group management, not authorship: a creator later demoted from the
|
|
3553
|
-
// admin roster shouldn't retain a side door to reassign who can read
|
|
3554
|
-
// or write the group record. Same gate as update/associate/delete.
|
|
3555
|
-
if (!this.isGroupManager(record))
|
|
3556
|
-
throw await this.denialFor(record);
|
|
3557
|
-
}
|
|
3558
|
-
else {
|
|
3559
|
-
// Intersected like every other authority here: the principal must
|
|
3560
|
-
// hold the verb, and the subject must be able to reach this record —
|
|
3561
|
-
// without which an owner principal would carry its subject to records
|
|
3562
|
-
// the subject cannot touch. create() withholds the same reach via
|
|
3563
|
-
// mayGrantAccess().
|
|
3564
|
-
if (!this.mayReshare(this.principalEntityId, record))
|
|
3565
|
-
throw await this.denialFor(record);
|
|
3566
|
-
if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
|
|
3567
|
-
throw await this.denialFor(record);
|
|
3568
|
-
}
|
|
3569
|
-
}
|
|
3570
|
-
// Reached through its own gate rather than requireUpdatable(), so the
|
|
3571
|
-
// soft-delete refusal has to be asked here too — after the authority
|
|
3572
|
-
// decision above, for the reason refuseIfDeleted() gives.
|
|
3573
|
-
this.refuseIfDeleted(record, true);
|
|
3574
|
-
return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
|
|
3575
|
-
}
|
|
3576
|
-
/**
|
|
3577
|
-
* Withhold a record from enumeration, or restore it — gated exactly like
|
|
3578
|
-
* setPermissions(), since both decide who or what can discover the
|
|
3579
|
-
* record rather than merely read it once found. See
|
|
3580
|
-
* docs/spec/unlisted.md.
|
|
3581
|
-
*/
|
|
3582
|
-
async setUnlisted(id, unlisted, opts = {}) {
|
|
3583
|
-
const record = await this.stack.get(id);
|
|
3584
|
-
if (!record)
|
|
3585
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3586
|
-
await this.requireOwnerForGrantRecord(record);
|
|
3587
|
-
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3588
|
-
if (!this.isGroupManager(record))
|
|
3589
|
-
throw await this.denialFor(record);
|
|
3590
|
-
}
|
|
3591
|
-
else {
|
|
3592
|
-
if (!this.mayReshare(this.principalEntityId, record))
|
|
3593
|
-
throw await this.denialFor(record);
|
|
3594
|
-
if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
|
|
3595
|
-
throw await this.denialFor(record);
|
|
3596
|
-
}
|
|
3597
|
-
}
|
|
3598
|
-
// See setPermissions() above — same gate, same reason.
|
|
3599
|
-
this.refuseIfDeleted(record, true);
|
|
3600
|
-
return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
|
|
3601
|
-
}
|
|
3602
|
-
/**
|
|
3603
|
-
* Move a record into another container, or to the root with `null`.
|
|
3604
|
-
* Gated as an ordinary write on the record, plus read access to the
|
|
3605
|
-
* destination — the same reference gate `create()` applies to a
|
|
3606
|
-
* `parentId`, asked again here so a move cannot reach a container an
|
|
3607
|
-
* authoring call could not have named. Not reshare-gated like
|
|
3608
|
-
* setUnlisted(): containment decides which listings enumerate a record,
|
|
3609
|
-
* never who may read it, so a move discloses it to nobody who could not
|
|
3610
|
-
* already read it. The origin is ungated — knowing it requires reading
|
|
3611
|
-
* the record, which this caller has already had to do. See
|
|
3612
|
-
* docs/spec/access-control.md § Reference-creation gating.
|
|
3613
|
-
*/
|
|
3614
|
-
async setParent(id, parentId, opts = {}) {
|
|
3615
|
-
await this.requireUpdatable(id);
|
|
3616
|
-
if (parentId !== null && !(await this.canReadReferent(parentId))) {
|
|
3617
|
-
throw new StackPermissionError();
|
|
3618
|
-
}
|
|
3619
|
-
return this.stack.setParent(id, parentId, { ...opts, ...this.actor });
|
|
3620
|
-
}
|
|
3621
3715
|
/**
|
|
3622
3716
|
* Hard delete is owner-only: it is irreversible and destroys version
|
|
3623
3717
|
* history, so neither the write bit nor delete-own/delete-any grants
|
|
@@ -3642,7 +3736,7 @@ export class ScopedStack {
|
|
|
3642
3736
|
}
|
|
3643
3737
|
/**
|
|
3644
3738
|
* History is the mutation/recovery surface, not a read surface — gated
|
|
3645
|
-
* like
|
|
3739
|
+
* like patchContent(), with snapshot `permissions` stripped for everyone but
|
|
3646
3740
|
* the owner acting alone — a snapshot's permissions are the stack's
|
|
3647
3741
|
* sharing graph, which delegation is not a route to. Reading history
|
|
3648
3742
|
* changes nothing, so it is the one path the `_grant` write fence leaves
|
|
@@ -3668,7 +3762,7 @@ export class ScopedStack {
|
|
|
3668
3762
|
* Re-runs the reference-creation checks against the snapshot, so a
|
|
3669
3763
|
* restore can't re-convey access to a file or record the subject can no
|
|
3670
3764
|
* longer reach today — the snapshot's `parentId` among them, gated
|
|
3671
|
-
* exactly as
|
|
3765
|
+
* exactly as a change set's `parentId` gates a destination named directly. Only the
|
|
3672
3766
|
* owner acting alone is exempt: under delegation the checks resolve
|
|
3673
3767
|
* against the subject, which is whose reach the restore would widen.
|
|
3674
3768
|
* See docs/spec/versioning.md § Restore semantics.
|
|
@@ -3679,7 +3773,7 @@ export class ScopedStack {
|
|
|
3679
3773
|
const target = await this.stack.getVersion(id, version);
|
|
3680
3774
|
if (target) {
|
|
3681
3775
|
// A rollback that would move a card's binding is the same trust
|
|
3682
|
-
// decision
|
|
3776
|
+
// decision a content patch reserves to the owner, reached by another route.
|
|
3683
3777
|
this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
|
|
3684
3778
|
record.content[field]);
|
|
3685
3779
|
// Only a restore that moves the record *into* a container creates a
|
|
@@ -3693,8 +3787,15 @@ export class ScopedStack {
|
|
|
3693
3787
|
throw new StackPermissionError();
|
|
3694
3788
|
}
|
|
3695
3789
|
await this.requireFileRefAccess(target.typeId, target.content);
|
|
3696
|
-
|
|
3697
|
-
|
|
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
|
+
}
|
|
3698
3799
|
}
|
|
3699
3800
|
}
|
|
3700
3801
|
}
|
|
@@ -3712,7 +3813,7 @@ export class ScopedStack {
|
|
|
3712
3813
|
* The restriction is what makes the verb safe to expose at all. Migrate
|
|
3713
3814
|
* replaces `content` and `typeId` wholesale, so a grant-based version
|
|
3714
3815
|
* would have to re-derive every gate `create()` applies at the
|
|
3715
|
-
* destination *and* every gate `
|
|
3816
|
+
* destination *and* every gate `mutate()` applies over the existing
|
|
3716
3817
|
* content, and would reopen each one it missed. The sharpest is the
|
|
3717
3818
|
* non-owner `_attachment@1` refusal create() carries: without it, a
|
|
3718
3819
|
* requester holding a create grant on `_attachment@1` and write access
|