@haverstack/core 0.37.0 → 0.38.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 +19 -15
- package/dist/access.d.ts +4 -6
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +7 -8
- package/dist/access.js.map +1 -1
- package/dist/adapter-entry.d.ts +7 -6
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +6 -5
- package/dist/adapter-entry.js.map +1 -1
- package/dist/attachment-download.d.ts +3 -1
- package/dist/attachment-download.d.ts.map +1 -1
- package/dist/attachment-download.js +1 -1
- package/dist/attachment-download.js.map +1 -1
- package/dist/changes.d.ts +22 -16
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +43 -32
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts +1 -1
- package/dist/combine.js +6 -6
- package/dist/combine.js.map +1 -1
- package/dist/errors.d.ts +24 -12
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +32 -17
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +15 -24
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +14 -13
- package/dist/grants.js.map +1 -1
- package/dist/identity-bindings.js +2 -2
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/query-validation.d.ts +11 -11
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +41 -36
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +11 -10
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +33 -25
- package/dist/record-changes.js.map +1 -1
- package/dist/record-id.d.ts +3 -3
- package/dist/record-id.js +8 -8
- package/dist/record-id.js.map +1 -1
- package/dist/scoped-stack.d.ts +71 -34
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +107 -88
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack-reads.d.ts +2 -2
- package/dist/stack-reads.js +5 -5
- package/dist/stack-reads.js.map +1 -1
- package/dist/stack.d.ts +132 -161
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +287 -288
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +17 -17
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +46 -42
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +169 -143
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/wire-entry.d.ts +5 -6
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +5 -11
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-record.d.ts +4 -4
- package/dist/wire-record.d.ts.map +1 -1
- package/dist/wire-record.js +10 -10
- package/dist/wire-record.js.map +1 -1
- package/dist/wire-request.d.ts +2 -2
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +103 -61
- package/dist/wire-request.js.map +1 -1
- package/package.json +1 -1
package/dist/stack.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - Content validation on write
|
|
10
10
|
* - Migration registry and explicit, owner-driven migrateAll()
|
|
11
11
|
* - Version snapshotting on update
|
|
12
|
-
* - Soft and
|
|
12
|
+
* - Soft delete and purge
|
|
13
13
|
*
|
|
14
14
|
* Apps should never talk to a StackAdapter directly.
|
|
15
15
|
*/
|
|
@@ -21,15 +21,15 @@ import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './a
|
|
|
21
21
|
import { compareRecordedAttachments } from './attachment-download.js';
|
|
22
22
|
import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
|
|
23
23
|
import { SYSTEM_TYPES } from './types.js';
|
|
24
|
-
import {
|
|
25
|
-
import { assertQueryCapabilities, assertSortCapability,
|
|
24
|
+
import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
|
|
25
|
+
import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
|
|
26
26
|
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
|
|
27
27
|
import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
|
|
28
28
|
import { assertAttachmentSize, assertContentSize } from './limits.js';
|
|
29
29
|
import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
|
|
30
30
|
import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
|
|
31
31
|
import { associationDelta, associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
|
|
32
|
-
import { ScopedStack } from './scoped-stack.js';
|
|
32
|
+
import { ScopedStack, scopeToken } from './scoped-stack.js';
|
|
33
33
|
// -------------------------------------------------------
|
|
34
34
|
// Supporting types
|
|
35
35
|
// -------------------------------------------------------
|
|
@@ -46,6 +46,25 @@ const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
46
46
|
* § Reparenting.
|
|
47
47
|
*/
|
|
48
48
|
const MAX_PARENT_DEPTH = 64;
|
|
49
|
+
/**
|
|
50
|
+
* An Actor in the one form `Stack` stores: a `principalId` equal to the
|
|
51
|
+
* subject is dropped, so "acted as itself" has a single spelling, and an
|
|
52
|
+
* empty id is refused rather than stored as a name for nobody.
|
|
53
|
+
* See docs/spec/data-model.md § Actor.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeActor(actor) {
|
|
56
|
+
if (!actor)
|
|
57
|
+
return undefined;
|
|
58
|
+
for (const field of ['subjectId', 'principalId']) {
|
|
59
|
+
if (actor[field] === '') {
|
|
60
|
+
throw new StackBadRequestError(`Invalid ${field}: the empty string is not an id.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const { subjectId, principalId } = actor;
|
|
64
|
+
return principalId === undefined || principalId === subjectId
|
|
65
|
+
? { subjectId }
|
|
66
|
+
: { subjectId, principalId };
|
|
67
|
+
}
|
|
49
68
|
/** Sentinel: filter.baseId resolved to zero matching types. */
|
|
50
69
|
const EMPTY_FAMILY = Symbol('empty-family');
|
|
51
70
|
/**
|
|
@@ -109,27 +128,7 @@ export class Stack {
|
|
|
109
128
|
static createActor(record) {
|
|
110
129
|
if (!record.updatedBy)
|
|
111
130
|
return undefined;
|
|
112
|
-
return {
|
|
113
|
-
entityId: record.updatedBy,
|
|
114
|
-
...(record.updatedVia !== undefined && { principalId: record.updatedVia }),
|
|
115
|
-
...(record.appId !== undefined && { appId: record.appId }),
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* The requester behind a write that stamps nothing on the record itself —
|
|
120
|
-
* a hard delete, which leaves no record to stamp, and associate()/
|
|
121
|
-
* dissociate(), which don't bump and so don't touch updatedBy/updatedVia.
|
|
122
|
-
* Hard delete is owner-acting-alone only, so it never has a principal to
|
|
123
|
-
* name beside the subject; associate()/dissociate() can run delegated,
|
|
124
|
-
* so `updatedVia` rides along here when present.
|
|
125
|
-
*/
|
|
126
|
-
static actorFrom(opts) {
|
|
127
|
-
if (!opts.updatedBy)
|
|
128
|
-
return undefined;
|
|
129
|
-
return {
|
|
130
|
-
entityId: opts.updatedBy,
|
|
131
|
-
...(opts.updatedVia !== undefined && { principalId: opts.updatedVia }),
|
|
132
|
-
};
|
|
131
|
+
return { ...record.updatedBy, ...(record.appId !== undefined && { appId: record.appId }) };
|
|
133
132
|
}
|
|
134
133
|
async getTypeCached(id) {
|
|
135
134
|
const cached = this.typeCache.get(id);
|
|
@@ -141,12 +140,12 @@ export class Stack {
|
|
|
141
140
|
return type;
|
|
142
141
|
}
|
|
143
142
|
/**
|
|
144
|
-
*
|
|
143
|
+
* Open a Stack over an adapter. Reads ownerEntityId and timezone from the adapter.
|
|
145
144
|
*/
|
|
146
|
-
static async
|
|
145
|
+
static async open(adapter, opts = {}) {
|
|
147
146
|
if (!adapter.ownerEntityId) {
|
|
148
|
-
throw new
|
|
149
|
-
'
|
|
147
|
+
throw new InvalidAdapterError('Invalid adapter: adapter has no ownerEntityId. ' +
|
|
148
|
+
'Initialize the adapter with an ownerEntityId before calling Stack.open().');
|
|
150
149
|
}
|
|
151
150
|
const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs);
|
|
152
151
|
await stack.seedSystemTypes();
|
|
@@ -180,7 +179,7 @@ export class Stack {
|
|
|
180
179
|
return this.adapter.ownerEntityId;
|
|
181
180
|
}
|
|
182
181
|
async getEntityByDid(did) {
|
|
183
|
-
return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.
|
|
182
|
+
return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.capabilities), true);
|
|
184
183
|
}
|
|
185
184
|
async getOwnerEntity() {
|
|
186
185
|
return this.getEntityByDid(this.ownerEntityId);
|
|
@@ -188,42 +187,43 @@ export class Stack {
|
|
|
188
187
|
get timezone() {
|
|
189
188
|
return this.adapter.timezone;
|
|
190
189
|
}
|
|
191
|
-
get
|
|
190
|
+
get capabilities() {
|
|
192
191
|
return this.adapter.capabilities;
|
|
193
192
|
}
|
|
194
193
|
/**
|
|
195
194
|
* Get a permission-scoped view of this Stack, as if requests came from
|
|
196
|
-
* the given
|
|
197
|
-
* unscoped; use asEntity() when one Stack serves multiple,
|
|
198
|
-
* untrusted, entities.
|
|
199
|
-
*
|
|
200
|
-
* `onBehalfOf` names the subject a delegated app acts for: authority is
|
|
201
|
-
* then the intersection of both parties' grants, while authorship and
|
|
202
|
-
* `-own` resolve against the subject. See
|
|
195
|
+
* the given entity acting as itself (null = anonymous). Plain Stack
|
|
196
|
+
* methods are unscoped; use asEntity() when one Stack serves multiple,
|
|
197
|
+
* possibly untrusted, entities. See
|
|
203
198
|
* docs/spec/access-control.md § Enforcement: Stack.asEntity().
|
|
204
199
|
*/
|
|
205
|
-
asEntity(entityId
|
|
206
|
-
this.
|
|
207
|
-
if (opts.onBehalfOf && !entityId) {
|
|
208
|
-
throw new StackPermissionError('An anonymous principal cannot act on behalf of an entity');
|
|
209
|
-
}
|
|
210
|
-
return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter, this.changes);
|
|
200
|
+
asEntity(entityId) {
|
|
201
|
+
return entityId === null ? this.scope(null, null) : this.asActor({ subjectId: entityId });
|
|
211
202
|
}
|
|
212
203
|
/**
|
|
213
|
-
* Scope to an
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* case where they are equal — it would surface only once delegation is in
|
|
220
|
-
* use, as authority no longer fenced by the app's grants and every write
|
|
221
|
-
* attributed to the app rather than the person. Taking the pair whole
|
|
222
|
-
* removes the order to get wrong. See
|
|
223
|
-
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
204
|
+
* Scope to an Actor — a delegated app acting for its user, or a
|
|
205
|
+
* TokenSession exactly as StackTokenStore.lookupToken() returns it, which
|
|
206
|
+
* is what a server should reach for at its request boundary. Authority is
|
|
207
|
+
* the intersection of both parties' grants, while authorship and `-own`
|
|
208
|
+
* resolve against the subject. Taking the pair whole leaves no order to
|
|
209
|
+
* swap. See docs/spec/access-control.md § Delegation: principal and subject.
|
|
224
210
|
*/
|
|
225
|
-
|
|
226
|
-
|
|
211
|
+
asActor(actor) {
|
|
212
|
+
const { subjectId, principalId = subjectId } = normalizeActor(actor);
|
|
213
|
+
return this.scope(principalId, subjectId);
|
|
214
|
+
}
|
|
215
|
+
scope(principalId, subjectId) {
|
|
216
|
+
this.assertOpen();
|
|
217
|
+
return new ScopedStack(scopeToken, {
|
|
218
|
+
stack: this,
|
|
219
|
+
principalId,
|
|
220
|
+
subjectId,
|
|
221
|
+
idTimestampSkewMs: this.idTimestampSkewMsValue,
|
|
222
|
+
adapter: this.adapter,
|
|
223
|
+
changes: this.changes,
|
|
224
|
+
assertOpen: () => this.assertOpen(),
|
|
225
|
+
relaysChanges: this.relaysChanges,
|
|
226
|
+
});
|
|
227
227
|
}
|
|
228
228
|
// -------------------------------------------------------
|
|
229
229
|
// Types
|
|
@@ -235,11 +235,11 @@ export class Stack {
|
|
|
235
235
|
* anything beyond additive evolution throws StackSchemaDriftError. See
|
|
236
236
|
* docs/spec/data-model.md § Schema drift detection.
|
|
237
237
|
*/
|
|
238
|
-
async defineType(id, name, schema,
|
|
238
|
+
async defineType({ id, name, schema, migratesFrom }) {
|
|
239
239
|
this.assertOpen();
|
|
240
240
|
const parsed = parseTypeId(id);
|
|
241
241
|
if (!parsed) {
|
|
242
|
-
throw new
|
|
242
|
+
throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
|
|
243
243
|
}
|
|
244
244
|
// Register this version even on the idempotent-no-op path —
|
|
245
245
|
// presentAtLatest()'s stale-writer detection depends on it.
|
|
@@ -284,7 +284,7 @@ export class Stack {
|
|
|
284
284
|
schema,
|
|
285
285
|
schemaHash,
|
|
286
286
|
createdAt: existing?.createdAt ?? new Date(),
|
|
287
|
-
...(
|
|
287
|
+
...(migratesFrom && { migratesFrom }),
|
|
288
288
|
};
|
|
289
289
|
await this.adapter.saveType(type);
|
|
290
290
|
this.typeCache.set(id, type);
|
|
@@ -374,12 +374,12 @@ export class Stack {
|
|
|
374
374
|
* too, validates each result before writing, and aborts on the first
|
|
375
375
|
* validation failure. See docs/spec/data-model.md § Type migrations.
|
|
376
376
|
*/
|
|
377
|
-
async migrateAll(
|
|
377
|
+
async migrateAll(baseId) {
|
|
378
378
|
this.assertOpen();
|
|
379
379
|
const types = await this.adapter.listTypes();
|
|
380
|
-
const familyTypeIds = types.filter((t) => t.baseId ===
|
|
380
|
+
const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
|
|
381
381
|
if (familyTypeIds.length === 0) {
|
|
382
|
-
throw new StackMigrationError(`migrateAll: no registered types found for
|
|
382
|
+
throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
|
|
383
383
|
}
|
|
384
384
|
let migrated = 0;
|
|
385
385
|
for (const typeId of familyTypeIds) {
|
|
@@ -431,7 +431,7 @@ export class Stack {
|
|
|
431
431
|
this.assertOpen();
|
|
432
432
|
const type = await this.getTypeCached(typeId);
|
|
433
433
|
if (!type) {
|
|
434
|
-
throw new
|
|
434
|
+
throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
|
|
435
435
|
}
|
|
436
436
|
// Copied, never aliased: an import loop that reuses one Date across rows
|
|
437
437
|
// (`d.setTime(...)` per record) would otherwise retro-edit every record
|
|
@@ -468,7 +468,7 @@ export class Stack {
|
|
|
468
468
|
if (errors.length > 0) {
|
|
469
469
|
throw new StackValidationError(errors);
|
|
470
470
|
}
|
|
471
|
-
assertContentSize(content, this.
|
|
471
|
+
assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
|
|
472
472
|
if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
473
473
|
await this.checkAttachmentMimeTypeOnCreate(content);
|
|
474
474
|
}
|
|
@@ -489,8 +489,9 @@ export class Stack {
|
|
|
489
489
|
if (opts.parentId !== undefined)
|
|
490
490
|
await this.assertNoParentCycle(opts.id, opts.parentId);
|
|
491
491
|
}
|
|
492
|
+
const createdBy = normalizeActor(opts.createdBy);
|
|
492
493
|
const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
|
|
493
|
-
? stampGroupAdmin(opts.associations,
|
|
494
|
+
? stampGroupAdmin(opts.associations, createdBy?.subjectId ?? this.ownerEntityId)
|
|
494
495
|
: opts.associations;
|
|
495
496
|
// createdAt drives the id, so the two agree by construction rather than
|
|
496
497
|
// by coincidence. An explicit one mints via generateIdForTimestamp(),
|
|
@@ -504,10 +505,8 @@ export class Stack {
|
|
|
504
505
|
// An id field a caller supplies is a value or it is absent — never the
|
|
505
506
|
// empty string, which names nobody. Refused rather than dropped, so the
|
|
506
507
|
// caller is never silently ignored.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
throw new StackQueryError(`Invalid ${field}: the empty string is not an id.`);
|
|
510
|
-
}
|
|
508
|
+
if (opts.appId === '') {
|
|
509
|
+
throw new StackBadRequestError('Invalid appId: the empty string is not an id.');
|
|
511
510
|
}
|
|
512
511
|
// Every create naming a parent owes the reference check, whether or not
|
|
513
512
|
// it supplied an id: a destination a caller names has to exist.
|
|
@@ -523,14 +522,11 @@ export class Stack {
|
|
|
523
522
|
// Presence, not truthiness: '' is refused above, so absence is the
|
|
524
523
|
// only thing a falsy value could mean here.
|
|
525
524
|
...(opts.parentId !== undefined && { parentId: opts.parentId }),
|
|
526
|
-
...(opts.entityId !== undefined && { entityId: opts.entityId }),
|
|
527
525
|
...(opts.appId !== undefined && { appId: opts.appId }),
|
|
528
|
-
|
|
529
|
-
//
|
|
530
|
-
// taken: stamping them here keeps "absent means an unscoped write"
|
|
526
|
+
// A create's actor is its author, so `updatedBy` is derived rather
|
|
527
|
+
// than taken: stamping it here keeps "absent means an unscoped write"
|
|
531
528
|
// true of version 1 as it is of every later version.
|
|
532
|
-
...(
|
|
533
|
-
...(opts.principalId && { updatedVia: opts.principalId }),
|
|
529
|
+
...(createdBy && { createdBy, updatedBy: createdBy }),
|
|
534
530
|
...(opts.permissions?.length && { permissions: opts.permissions }),
|
|
535
531
|
...(associations?.length && { associations }),
|
|
536
532
|
...(opts.unlisted && { unlistedAt: createdAt }),
|
|
@@ -614,11 +610,11 @@ export class Stack {
|
|
|
614
610
|
// A change set naming only aspects the journal already keeps in full
|
|
615
611
|
// doesn't bump — the same rule associate()/dissociate() follow
|
|
616
612
|
// unconditionally. Its actor travels as an explicit opt rather than a
|
|
617
|
-
// record stamp, since a non-bumping write never touches
|
|
618
|
-
//
|
|
613
|
+
// record stamp, since a non-bumping write never touches `updatedBy`.
|
|
614
|
+
// See docs/spec/versioning.md § Version history.
|
|
619
615
|
const bumps = bumpsVersion(ops);
|
|
620
616
|
// Computed against the same before/after changeSetOps compared, so
|
|
621
|
-
// whether associate/dissociate/
|
|
617
|
+
// whether associate/dissociate/reshare appear in `ops` and what
|
|
622
618
|
// the journal lists can never disagree. Both halves of the partition
|
|
623
619
|
// produce the same tagged edits and travel as one list: the journal's
|
|
624
620
|
// argument is prior state, and an ACL element's is no different from a
|
|
@@ -633,17 +629,12 @@ export class Stack {
|
|
|
633
629
|
];
|
|
634
630
|
const previousParentId = existing.parentId ?? null;
|
|
635
631
|
const change = new PendingChange(ops, {
|
|
636
|
-
actor:
|
|
632
|
+
actor: normalizeActor(opts.actor),
|
|
637
633
|
...(ops.includes('reparent') && { previousParentId }),
|
|
638
634
|
...(assocDelta.length && { associations: assocDelta }),
|
|
639
635
|
});
|
|
640
636
|
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
|
|
641
|
-
...(bumps
|
|
642
|
-
? this.writeOptions(existing, opts)
|
|
643
|
-
: {
|
|
644
|
-
updatedBy: opts.updatedBy,
|
|
645
|
-
updatedVia: opts.updatedVia,
|
|
646
|
-
}),
|
|
637
|
+
...(bumps ? this.writeOptions(existing, opts) : { actor: normalizeActor(opts.actor) }),
|
|
647
638
|
bumpsVersion: bumps,
|
|
648
639
|
journal: change.journal,
|
|
649
640
|
});
|
|
@@ -700,10 +691,10 @@ export class Stack {
|
|
|
700
691
|
if (contentPatch) {
|
|
701
692
|
// The patch is what travels, so the patch is what's measured — a
|
|
702
693
|
// small patch against a large record is not an oversized request.
|
|
703
|
-
assertContentSize(contentPatch, this.
|
|
694
|
+
assertContentSize(contentPatch, this.capabilities.limits.contentBytes, 'Patch');
|
|
704
695
|
const type = await this.getTypeCached(existing.typeId);
|
|
705
696
|
if (!type) {
|
|
706
|
-
throw new
|
|
697
|
+
throw new StackBadRequestError(`Unknown type: "${existing.typeId}"`);
|
|
707
698
|
}
|
|
708
699
|
merged = applyMergePatch(existing.content, contentPatch);
|
|
709
700
|
const contentErrors = [
|
|
@@ -781,7 +772,7 @@ export class Stack {
|
|
|
781
772
|
const replaced = (existing.associations ?? []).find((a) => associationEqual(a, association));
|
|
782
773
|
await this.checkAttachmentAssociationPointers([association]);
|
|
783
774
|
const change = new PendingChange('associate', {
|
|
784
|
-
actor:
|
|
775
|
+
actor: normalizeActor(opts.actor),
|
|
785
776
|
// A re-point carries what it overwrote: nothing else retains the
|
|
786
777
|
// attachmentRecordId that association held.
|
|
787
778
|
associations: [
|
|
@@ -821,7 +812,7 @@ export class Stack {
|
|
|
821
812
|
this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
|
|
822
813
|
}
|
|
823
814
|
const change = new PendingChange('dissociate', {
|
|
824
|
-
actor:
|
|
815
|
+
actor: normalizeActor(opts.actor),
|
|
825
816
|
// In full, annotation included — what makes a removal as undoable
|
|
826
817
|
// from the log as a re-point is. The frame this becomes still names
|
|
827
818
|
// identity only. See docs/spec/journal.md § The entry.
|
|
@@ -849,8 +840,8 @@ export class Stack {
|
|
|
849
840
|
if (current.some((p) => associationEqual(p, permission)))
|
|
850
841
|
return existing;
|
|
851
842
|
this.assertPermissionSet([...current, permission]);
|
|
852
|
-
const change = new PendingChange('
|
|
853
|
-
actor:
|
|
843
|
+
const change = new PendingChange('reshare', {
|
|
844
|
+
actor: normalizeActor(opts.actor),
|
|
854
845
|
associations: [{ op: 'add', association: permission }],
|
|
855
846
|
});
|
|
856
847
|
const updated = await this.adapter.associate(id, permission, { journal: change.journal });
|
|
@@ -874,8 +865,8 @@ export class Stack {
|
|
|
874
865
|
if (!matched)
|
|
875
866
|
return existing;
|
|
876
867
|
this.assertPermissionSet(current.filter((p) => !associationEqual(p, permission)));
|
|
877
|
-
const change = new PendingChange('
|
|
878
|
-
actor:
|
|
868
|
+
const change = new PendingChange('reshare', {
|
|
869
|
+
actor: normalizeActor(opts.actor),
|
|
879
870
|
associations: [{ op: 'remove', previous: matched }],
|
|
880
871
|
});
|
|
881
872
|
const updated = await this.adapter.dissociate(id, permission, { journal: change.journal });
|
|
@@ -948,7 +939,7 @@ export class Stack {
|
|
|
948
939
|
}
|
|
949
940
|
}
|
|
950
941
|
/**
|
|
951
|
-
* Soft-delete a record (default) or
|
|
942
|
+
* Soft-delete a record (default) or purge it permanently, removing
|
|
952
943
|
* the record and its version history. Soft delete snapshots and bumps
|
|
953
944
|
* version; a no-op if already deleted. `_config` is never deletable
|
|
954
945
|
* (docs/spec.md § The `_config` record). See docs/spec/versioning.md
|
|
@@ -971,8 +962,8 @@ export class Stack {
|
|
|
971
962
|
}
|
|
972
963
|
/**
|
|
973
964
|
* delete(), reporting the record it acted on alongside the files it
|
|
974
|
-
* stranded: as it stood immediately before destruction for a
|
|
975
|
-
*
|
|
965
|
+
* stranded: as it stood immediately before destruction for a
|
|
966
|
+
* purge, or as the resulting tombstone for a soft delete. Both are
|
|
976
967
|
* captured inside the same write that destroys or tombstones the record,
|
|
977
968
|
* never a read beforehand — a read-then-delete would leave a window
|
|
978
969
|
* where a concurrent write can land, get destroyed or overwritten by
|
|
@@ -983,15 +974,15 @@ export class Stack {
|
|
|
983
974
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
984
975
|
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
985
976
|
}
|
|
986
|
-
if (opts.
|
|
977
|
+
if (opts.purge) {
|
|
987
978
|
// The adapter hands back what it destroyed, captured inside the same
|
|
988
979
|
// write: a read here instead would race the delete, and afterwards
|
|
989
980
|
// there is nothing left to read. Null means there was no record, so
|
|
990
981
|
// nothing was purged and nothing is announced.
|
|
991
|
-
const change = new PendingChange('
|
|
982
|
+
const change = new PendingChange('purge', { actor: normalizeActor(opts.actor) });
|
|
992
983
|
const purged = await this.adapter.deleteRecord(id, {
|
|
993
|
-
|
|
994
|
-
|
|
984
|
+
purge: true,
|
|
985
|
+
ifVersion: opts.ifVersion,
|
|
995
986
|
journal: change.journal,
|
|
996
987
|
});
|
|
997
988
|
if (!purged)
|
|
@@ -1008,7 +999,7 @@ export class Stack {
|
|
|
1008
999
|
// attachments intact — so nothing is stranded and nothing is reported.
|
|
1009
1000
|
if (existing.deletedAt)
|
|
1010
1001
|
return { record: existing, referencedFileIds: [] };
|
|
1011
|
-
const change = new PendingChange('delete', { actor:
|
|
1002
|
+
const change = new PendingChange('delete', { actor: normalizeActor(opts.actor) });
|
|
1012
1003
|
const deleted = await this.adapter.deleteRecord(id, {
|
|
1013
1004
|
...this.writeOptions(existing, opts),
|
|
1014
1005
|
journal: change.journal,
|
|
@@ -1033,7 +1024,7 @@ export class Stack {
|
|
|
1033
1024
|
}
|
|
1034
1025
|
/**
|
|
1035
1026
|
* Reverse a soft delete. Idempotent — undeleting a record that isn't
|
|
1036
|
-
* deleted returns it unchanged.
|
|
1027
|
+
* deleted returns it unchanged. Purged records are gone, so this
|
|
1037
1028
|
* throws StackNotFoundError for them just like any other missing record.
|
|
1038
1029
|
* Snapshots and bumps version, same as delete().
|
|
1039
1030
|
*/
|
|
@@ -1046,7 +1037,7 @@ export class Stack {
|
|
|
1046
1037
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1047
1038
|
if (!existing.deletedAt)
|
|
1048
1039
|
return existing;
|
|
1049
|
-
const change = new PendingChange('undelete', { actor:
|
|
1040
|
+
const change = new PendingChange('undelete', { actor: normalizeActor(opts.actor) });
|
|
1050
1041
|
const undeleted = await this.adapter.undeleteRecord(id, {
|
|
1051
1042
|
...this.writeOptions(existing, opts),
|
|
1052
1043
|
journal: change.journal,
|
|
@@ -1066,7 +1057,7 @@ export class Stack {
|
|
|
1066
1057
|
assertQueryCapabilities(filter, this.adapter.capabilities);
|
|
1067
1058
|
assertValidSort(query.sort);
|
|
1068
1059
|
assertSortCapability(query.sort, this.adapter.capabilities);
|
|
1069
|
-
|
|
1060
|
+
assertValidAssociationFilters(filter);
|
|
1070
1061
|
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
|
|
1071
1062
|
const resolvedFilter = await this.resolveBaseIdFilter(filter);
|
|
1072
1063
|
if (resolvedFilter === EMPTY_FAMILY) {
|
|
@@ -1145,7 +1136,7 @@ export class Stack {
|
|
|
1145
1136
|
}
|
|
1146
1137
|
const type = await this.getTypeCached(target.typeId);
|
|
1147
1138
|
if (!type) {
|
|
1148
|
-
throw new
|
|
1139
|
+
throw new StackBadRequestError(`Unknown type: "${target.typeId}"`);
|
|
1149
1140
|
}
|
|
1150
1141
|
const errors = [
|
|
1151
1142
|
...validateContent(target.content, type.schema),
|
|
@@ -1168,7 +1159,10 @@ export class Stack {
|
|
|
1168
1159
|
// A restore adds no containment edge and takes none away, so there is
|
|
1169
1160
|
// no cycle for it to close and nothing for the destination checks to
|
|
1170
1161
|
// gate. See docs/spec/versioning.md § Restore semantics.
|
|
1171
|
-
const change = new PendingChange('restore', {
|
|
1162
|
+
const change = new PendingChange('restore', {
|
|
1163
|
+
actor: normalizeActor(opts.actor),
|
|
1164
|
+
previousTypeId: existing.typeId,
|
|
1165
|
+
});
|
|
1172
1166
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1173
1167
|
...this.writeOptions(existing, opts),
|
|
1174
1168
|
journal: change.journal,
|
|
@@ -1216,7 +1210,7 @@ export class Stack {
|
|
|
1216
1210
|
const id = existing.id;
|
|
1217
1211
|
const type = await this.getTypeCached(toTypeId);
|
|
1218
1212
|
if (!type) {
|
|
1219
|
-
throw new
|
|
1213
|
+
throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
|
|
1220
1214
|
}
|
|
1221
1215
|
const errors = [
|
|
1222
1216
|
...validateReservedKeys(content),
|
|
@@ -1227,7 +1221,7 @@ export class Stack {
|
|
|
1227
1221
|
if (errors.length > 0) {
|
|
1228
1222
|
throw new StackValidationError(errors);
|
|
1229
1223
|
}
|
|
1230
|
-
assertContentSize(content, this.
|
|
1224
|
+
assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
|
|
1231
1225
|
const fromFamily = baseIdOf(existing.typeId);
|
|
1232
1226
|
const toFamily = baseIdOf(toTypeId);
|
|
1233
1227
|
const existingContent = existing.content;
|
|
@@ -1257,7 +1251,10 @@ export class Stack {
|
|
|
1257
1251
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1258
1252
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1259
1253
|
}
|
|
1260
|
-
const change = new PendingChange('migrate', {
|
|
1254
|
+
const change = new PendingChange('migrate', {
|
|
1255
|
+
actor: normalizeActor(opts.actor),
|
|
1256
|
+
previousTypeId: existing.typeId,
|
|
1257
|
+
});
|
|
1261
1258
|
const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
|
|
1262
1259
|
...this.writeOptions(existing, opts),
|
|
1263
1260
|
journal: change.journal,
|
|
@@ -1317,8 +1314,8 @@ export class Stack {
|
|
|
1317
1314
|
}
|
|
1318
1315
|
}
|
|
1319
1316
|
/**
|
|
1320
|
-
* A unique binding field is what a lookup resolves *by* —
|
|
1321
|
-
* `principalId` by `_app.did`, its `
|
|
1317
|
+
* A unique binding field is what a lookup resolves *by* — an Actor's
|
|
1318
|
+
* `principalId` by `_app.did`, its `subjectId` by `_entity.did`. Two cards
|
|
1322
1319
|
* claiming one value would leave that lookup without a single answer, and
|
|
1323
1320
|
* ambiguity is all an impersonating card needs. Enforced here rather than
|
|
1324
1321
|
* by schema, since uniqueness is a property of the set, not of the value.
|
|
@@ -1336,7 +1333,7 @@ export class Stack {
|
|
|
1336
1333
|
baseId: family,
|
|
1337
1334
|
includeDeleted: true,
|
|
1338
1335
|
includeUnlisted: true,
|
|
1339
|
-
...(filtersContent(this.
|
|
1336
|
+
...(filtersContent(this.capabilities) && { content: { [field]: value } }),
|
|
1340
1337
|
},
|
|
1341
1338
|
}, (r) => r.id !== excludeId && r.content[field] === value);
|
|
1342
1339
|
if (clash) {
|
|
@@ -1476,19 +1473,20 @@ export class Stack {
|
|
|
1476
1473
|
}
|
|
1477
1474
|
/**
|
|
1478
1475
|
* Store bytes and create an _attachment@1 metadata record (owner-
|
|
1479
|
-
* attributed, no
|
|
1476
|
+
* attributed, no createdBy), returning that record — `content.fileId`
|
|
1480
1477
|
* addresses the bytes, `id` addresses the metadata. Delegates to the
|
|
1481
1478
|
* adapter's atomic putAttachmentWithMetadata() when implemented, trusting
|
|
1482
1479
|
* the returned record as backend-authoritative; otherwise falls back to
|
|
1483
1480
|
* bytes-then-create(). See docs/spec/wire-format.md § Attachments.
|
|
1484
1481
|
*/
|
|
1485
|
-
async putAttachment(data,
|
|
1482
|
+
async putAttachment(data, opts) {
|
|
1483
|
+
const { mimeType, filename, appId } = opts;
|
|
1486
1484
|
this.assertOpen();
|
|
1487
|
-
assertAttachmentSize(data.byteLength, this.
|
|
1485
|
+
assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
|
|
1488
1486
|
if (this.adapter.putAttachmentWithMetadata) {
|
|
1489
1487
|
// The metadata record is written inside the adapter, so create()
|
|
1490
1488
|
// never sees it and this is the only place it can be announced.
|
|
1491
|
-
const record = await this.adapter.putAttachmentWithMetadata(data,
|
|
1489
|
+
const record = await this.adapter.putAttachmentWithMetadata(data, opts);
|
|
1492
1490
|
// The one emission with no journal half of its own: the far side
|
|
1493
1491
|
// wrote the record, so it appended the entry in that same write —
|
|
1494
1492
|
// the same division saveVersion() follows over this adapter. See
|
|
@@ -1496,7 +1494,7 @@ export class Stack {
|
|
|
1496
1494
|
this.announce(new PendingChange('create'), record);
|
|
1497
1495
|
return record;
|
|
1498
1496
|
}
|
|
1499
|
-
const fileId = await this.adapter.
|
|
1497
|
+
const fileId = await this.adapter.putBlob(data);
|
|
1500
1498
|
return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
|
|
1501
1499
|
fileId,
|
|
1502
1500
|
mimeType,
|
|
@@ -1506,7 +1504,7 @@ export class Stack {
|
|
|
1506
1504
|
}
|
|
1507
1505
|
async getAttachment(fileId) {
|
|
1508
1506
|
this.assertOpen();
|
|
1509
|
-
return this.adapter.
|
|
1507
|
+
return this.adapter.getBlob(fileId);
|
|
1510
1508
|
}
|
|
1511
1509
|
/**
|
|
1512
1510
|
* Every `_attachment` record describing `fileId`, earliest-recorded
|
|
@@ -1527,7 +1525,7 @@ export class Stack {
|
|
|
1527
1525
|
baseId: SYSTEM_TYPES.ATTACHMENT,
|
|
1528
1526
|
includeDeleted: true,
|
|
1529
1527
|
includeUnlisted: true,
|
|
1530
|
-
...(filtersContent(this.
|
|
1528
|
+
...(filtersContent(this.capabilities) && { content: { fileId } }),
|
|
1531
1529
|
},
|
|
1532
1530
|
});
|
|
1533
1531
|
return results
|
|
@@ -1559,7 +1557,7 @@ export class Stack {
|
|
|
1559
1557
|
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
|
|
1560
1558
|
const at = new Date();
|
|
1561
1559
|
for (const record of deletedRecords) {
|
|
1562
|
-
this.announce(new PendingChange('
|
|
1560
|
+
this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
|
|
1563
1561
|
}
|
|
1564
1562
|
}
|
|
1565
1563
|
else {
|
|
@@ -1567,13 +1565,13 @@ export class Stack {
|
|
|
1567
1565
|
}
|
|
1568
1566
|
if (!deletedRecords.length) {
|
|
1569
1567
|
try {
|
|
1570
|
-
await this.adapter.
|
|
1568
|
+
await this.adapter.getBlob(fileId);
|
|
1571
1569
|
}
|
|
1572
1570
|
catch {
|
|
1573
1571
|
throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
|
|
1574
1572
|
}
|
|
1575
1573
|
}
|
|
1576
|
-
await this.adapter.
|
|
1574
|
+
await this.adapter.deleteBlob(fileId);
|
|
1577
1575
|
}
|
|
1578
1576
|
/**
|
|
1579
1577
|
* Non-atomic fallback for adapters that don't implement
|
|
@@ -1585,7 +1583,7 @@ export class Stack {
|
|
|
1585
1583
|
// must find its attachments intact on undelete or relisting. See
|
|
1586
1584
|
// docs/spec/attachments.md § Deleting attachments.
|
|
1587
1585
|
const refResult = await this.query({
|
|
1588
|
-
filter: {
|
|
1586
|
+
filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
|
|
1589
1587
|
limit: 1,
|
|
1590
1588
|
});
|
|
1591
1589
|
if (refResult.records.length > 0) {
|
|
@@ -1595,7 +1593,7 @@ export class Stack {
|
|
|
1595
1593
|
// too — none of it may be left pointing at deleted bytes.
|
|
1596
1594
|
const metaRecords = await this.getAttachmentRecords(fileId);
|
|
1597
1595
|
for (const record of metaRecords) {
|
|
1598
|
-
await this.delete(record.id, {
|
|
1596
|
+
await this.delete(record.id, { purge: true, ...opts });
|
|
1599
1597
|
}
|
|
1600
1598
|
return metaRecords;
|
|
1601
1599
|
}
|
|
@@ -1626,19 +1624,19 @@ export class Stack {
|
|
|
1626
1624
|
}
|
|
1627
1625
|
}
|
|
1628
1626
|
// Bare-bytes orphans: blobs with zero metadata records, only
|
|
1629
|
-
// discoverable if the blob adapter implements
|
|
1627
|
+
// discoverable if the blob adapter implements listBlobs().
|
|
1630
1628
|
const blobByFile = new Map();
|
|
1631
|
-
if (this.adapter.
|
|
1632
|
-
for (const file of await this.adapter.
|
|
1629
|
+
if (this.adapter.listBlobs) {
|
|
1630
|
+
for (const file of await this.adapter.listBlobs()) {
|
|
1633
1631
|
blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
|
|
1634
1632
|
}
|
|
1635
1633
|
}
|
|
1636
1634
|
const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
|
|
1637
|
-
const
|
|
1635
|
+
const deletedFileIds = [];
|
|
1638
1636
|
let reclaimedBytes = 0;
|
|
1639
1637
|
for (const fileId of candidateFileIds) {
|
|
1640
1638
|
const refResult = await this.query({
|
|
1641
|
-
filter: {
|
|
1639
|
+
filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
|
|
1642
1640
|
limit: 1,
|
|
1643
1641
|
});
|
|
1644
1642
|
if (refResult.records.length > 0)
|
|
@@ -1650,12 +1648,12 @@ export class Stack {
|
|
|
1650
1648
|
continue;
|
|
1651
1649
|
const size = meta?.size ?? blob?.size ?? 0;
|
|
1652
1650
|
if (dryRun) {
|
|
1653
|
-
|
|
1651
|
+
deletedFileIds.push(fileId);
|
|
1654
1652
|
reclaimedBytes += size;
|
|
1655
1653
|
continue;
|
|
1656
1654
|
}
|
|
1657
1655
|
try {
|
|
1658
|
-
await this.deleteAttachment(fileId, {
|
|
1656
|
+
await this.deleteAttachment(fileId, { actor: opts.actor });
|
|
1659
1657
|
}
|
|
1660
1658
|
catch (err) {
|
|
1661
1659
|
// Raced with a new reference, or another sweep/call already removed
|
|
@@ -1664,10 +1662,10 @@ export class Stack {
|
|
|
1664
1662
|
continue;
|
|
1665
1663
|
throw err;
|
|
1666
1664
|
}
|
|
1667
|
-
|
|
1665
|
+
deletedFileIds.push(fileId);
|
|
1668
1666
|
reclaimedBytes += size;
|
|
1669
1667
|
}
|
|
1670
|
-
return {
|
|
1668
|
+
return { deletedFileIds, reclaimedBytes };
|
|
1671
1669
|
}
|
|
1672
1670
|
// -------------------------------------------------------
|
|
1673
1671
|
// Lifecycle
|
|
@@ -1708,7 +1706,7 @@ export class Stack {
|
|
|
1708
1706
|
*
|
|
1709
1707
|
* The subscription's own filter goes to the relay rather than being
|
|
1710
1708
|
* applied on the way back: the emitter at the far end holds the record,
|
|
1711
|
-
* so it can answer `
|
|
1709
|
+
* so it can answer `createdBy` and `parentId`, which the envelope
|
|
1712
1710
|
* deliberately does not carry. That is also why a relay is opened per
|
|
1713
1711
|
* subscription rather than shared.
|
|
1714
1712
|
*/
|
|
@@ -1729,10 +1727,7 @@ export class Stack {
|
|
|
1729
1727
|
void stop();
|
|
1730
1728
|
};
|
|
1731
1729
|
}
|
|
1732
|
-
/**
|
|
1733
|
-
* Whether changes reach this stack from elsewhere. Public only so
|
|
1734
|
-
* ScopedStack can refuse a scope it cannot honor; not an app-facing API.
|
|
1735
|
-
*/
|
|
1730
|
+
/** Whether changes reach this stack from elsewhere. */
|
|
1736
1731
|
get relaysChanges() {
|
|
1737
1732
|
return typeof this.adapter.subscribeChanges === 'function';
|
|
1738
1733
|
}
|
|
@@ -1767,23 +1762,20 @@ export class Stack {
|
|
|
1767
1762
|
await this.adapter.close?.();
|
|
1768
1763
|
}
|
|
1769
1764
|
}
|
|
1770
|
-
/**
|
|
1771
|
-
* Throws once close() has run. Public only so ScopedStack can gate the
|
|
1772
|
-
* one path it takes to the adapter directly; not an app-facing API.
|
|
1773
|
-
*/
|
|
1765
|
+
/** Throws once close() has run. */
|
|
1774
1766
|
assertOpen() {
|
|
1775
1767
|
if (this.closed)
|
|
1776
|
-
throw new
|
|
1768
|
+
throw new UseAfterCloseError();
|
|
1777
1769
|
}
|
|
1778
1770
|
// -------------------------------------------------------
|
|
1779
1771
|
// Grants
|
|
1780
1772
|
// -------------------------------------------------------
|
|
1781
1773
|
/**
|
|
1782
|
-
* Create _grant
|
|
1783
|
-
*
|
|
1784
|
-
* `
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1774
|
+
* Create a _grant record authorizing `grantee` to take `actions` on
|
|
1775
|
+
* records of `typeId`: `{ kind: 'entity' }` for one DID, `{ kind: 'group' }`
|
|
1776
|
+
* for a `_group` Record's roster at a role, `{ kind: 'authenticated' }` for
|
|
1777
|
+
* any authenticated entity. The type-level mirror of grantAccess() —
|
|
1778
|
+
* subject first, grantee inside the element.
|
|
1787
1779
|
*
|
|
1788
1780
|
* Granting an **app** a `-own` action does not contain it the way the
|
|
1789
1781
|
* suffix suggests: when that app acts for someone, `-own` is read as the
|
|
@@ -1793,22 +1785,19 @@ export class Stack {
|
|
|
1793
1785
|
* needs, not the suffix that looks narrowest. See
|
|
1794
1786
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1795
1787
|
*
|
|
1796
|
-
*
|
|
1788
|
+
* `typeOrBaseId` is a versioned TypeId (`"baseId@version"`) or a bare
|
|
1789
|
+
* baseId; either names the whole family. See
|
|
1797
1790
|
* docs/spec/access-control.md § Type-level grants.
|
|
1798
1791
|
*/
|
|
1799
|
-
async
|
|
1792
|
+
async grantType(typeOrBaseId, grant) {
|
|
1800
1793
|
this.assertOpen();
|
|
1801
|
-
validateGrantTarget(
|
|
1802
|
-
this.
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
grantee: target,
|
|
1809
|
-
}));
|
|
1810
|
-
}
|
|
1811
|
-
return records;
|
|
1794
|
+
validateGrantTarget(grant.grantee);
|
|
1795
|
+
this.checkGrantValid(typeOrBaseId, grant.actions);
|
|
1796
|
+
return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1797
|
+
typeId: typeOrBaseId,
|
|
1798
|
+
actions: grant.actions,
|
|
1799
|
+
grantee: grant.grantee,
|
|
1800
|
+
});
|
|
1812
1801
|
}
|
|
1813
1802
|
/**
|
|
1814
1803
|
* List _grant records. Omit `query` for all grants;
|
|
@@ -1819,12 +1808,12 @@ export class Stack {
|
|
|
1819
1808
|
* (ones naming them, ones naming a group they belong to at a role they
|
|
1820
1809
|
* hold, plus every default grant) — the same resolution hasGrant() uses.
|
|
1821
1810
|
*
|
|
1822
|
-
* Every arm but `entity` answers identity — what `
|
|
1811
|
+
* Every arm but `entity` answers identity — what `grantType()` would have
|
|
1823
1812
|
* written with the same argument. The `entity` arm answers coverage, so
|
|
1824
|
-
* its result is not a preview of what `
|
|
1813
|
+
* its result is not a preview of what `revokeType()` would withdraw.
|
|
1825
1814
|
* See docs/spec/access-control.md § Type-level grants.
|
|
1826
1815
|
*/
|
|
1827
|
-
async
|
|
1816
|
+
async listTypeGrants(query) {
|
|
1828
1817
|
this.assertOpen();
|
|
1829
1818
|
if (query !== undefined)
|
|
1830
1819
|
validateGrantTarget(query, true);
|
|
@@ -1853,90 +1842,78 @@ export class Stack {
|
|
|
1853
1842
|
return result;
|
|
1854
1843
|
}
|
|
1855
1844
|
/**
|
|
1856
|
-
* The inverse of
|
|
1857
|
-
*
|
|
1858
|
-
* writes — the grantee is matched whole, role included
|
|
1859
|
-
* like any other — the owner can
|
|
1845
|
+
* The inverse of grantType(): soft-deletes the _grant records on
|
|
1846
|
+
* `typeOrBaseId`'s family matching `grant`, at the same granularity
|
|
1847
|
+
* grantType() writes — the grantee is matched whole, role included, and
|
|
1848
|
+
* the actions exactly. A soft delete like any other — the owner can
|
|
1849
|
+
* undelete a revocation.
|
|
1860
1850
|
*
|
|
1861
|
-
* Returns the grants it withdrew, as they stood
|
|
1862
|
-
*
|
|
1863
|
-
* is not an error — a
|
|
1864
|
-
*
|
|
1865
|
-
*
|
|
1851
|
+
* Returns the grants it withdrew, as they stood: an array, where
|
|
1852
|
+
* revokeAccess() returns one Record, because one target can match several
|
|
1853
|
+
* _grant records. An empty result is not an error — re-running a
|
|
1854
|
+
* revocation has to stay safe. See
|
|
1855
|
+
* docs/spec/access-control.md § Listing and revoking.
|
|
1866
1856
|
*/
|
|
1867
|
-
async
|
|
1857
|
+
async revokeType(typeOrBaseId, grant) {
|
|
1868
1858
|
this.assertOpen();
|
|
1869
|
-
validateGrantTarget(
|
|
1870
|
-
const
|
|
1859
|
+
validateGrantTarget(grant.grantee);
|
|
1860
|
+
const familyId = baseIdOf(typeOrBaseId);
|
|
1861
|
+
const actionSet = new Set(grant.actions);
|
|
1871
1862
|
const all = await loadGrantRecords((q) => this.query(q));
|
|
1872
|
-
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
for (const match of matches) {
|
|
1889
|
-
await this.delete(match.id);
|
|
1890
|
-
revoked.push(match);
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
return revoked;
|
|
1863
|
+
const matches = all.filter((r) => {
|
|
1864
|
+
const c = r.content;
|
|
1865
|
+
// Establishes the family and that `actions` is a list, so the exact
|
|
1866
|
+
// match below reads a real one. Matched against the stored list
|
|
1867
|
+
// rather than the reach, so a grant carrying an action this
|
|
1868
|
+
// vocabulary drops is not withdrawn by a target that omits it.
|
|
1869
|
+
const reach = grantReach(c);
|
|
1870
|
+
if (!reach || reach.familyId !== familyId)
|
|
1871
|
+
return false;
|
|
1872
|
+
if (!matchesGrantTarget(c, grant.grantee))
|
|
1873
|
+
return false;
|
|
1874
|
+
return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
|
|
1875
|
+
});
|
|
1876
|
+
for (const match of matches)
|
|
1877
|
+
await this.delete(match.id);
|
|
1878
|
+
return matches;
|
|
1894
1879
|
}
|
|
1895
1880
|
// -------------------------------------------------------
|
|
1896
1881
|
// Private helpers
|
|
1897
1882
|
// -------------------------------------------------------
|
|
1898
1883
|
/**
|
|
1899
|
-
*
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
* _grant/_config families are refused — see docs/spec/access-control.md
|
|
1903
|
-
* § Type-level grants.
|
|
1884
|
+
* Actions must be known GrantAction values, typeIds must be well-formed,
|
|
1885
|
+
* and the _grant/_config families are refused — see
|
|
1886
|
+
* docs/spec/access-control.md § Type-level grants.
|
|
1904
1887
|
*/
|
|
1905
|
-
|
|
1888
|
+
checkGrantValid(typeId, actions) {
|
|
1906
1889
|
const errors = [];
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1890
|
+
actions.forEach((action, j) => {
|
|
1891
|
+
if (!GRANT_ACTION_SET.has(action)) {
|
|
1892
|
+
errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
if (!isWellFormedTypeId(typeId)) {
|
|
1896
|
+
errors.push({
|
|
1897
|
+
path: 'typeId',
|
|
1898
|
+
message: `"${typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
|
|
1915
1899
|
});
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1900
|
+
}
|
|
1901
|
+
else if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(typeId))) {
|
|
1902
|
+
const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
|
|
1903
|
+
errors.push({
|
|
1904
|
+
path: 'typeId',
|
|
1905
|
+
message: `Cannot grant on "${baseIdOf(typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
actions.forEach((action, j) => {
|
|
1909
|
+
if (grantConveys(actions, action))
|
|
1921
1910
|
return;
|
|
1922
|
-
|
|
1923
|
-
if (
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
});
|
|
1929
|
-
}
|
|
1930
|
-
g.actions.forEach((action, j) => {
|
|
1931
|
-
if (grantConveys(g.actions, action))
|
|
1932
|
-
return;
|
|
1933
|
-
const companions = READ_COMPANIONS.get(action);
|
|
1934
|
-
if (!companions)
|
|
1935
|
-
return;
|
|
1936
|
-
errors.push({
|
|
1937
|
-
path: `grants[${i}].actions[${j}]`,
|
|
1938
|
-
message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read`,
|
|
1939
|
-
});
|
|
1911
|
+
const companions = READ_COMPANIONS.get(action);
|
|
1912
|
+
if (!companions)
|
|
1913
|
+
return;
|
|
1914
|
+
errors.push({
|
|
1915
|
+
path: `actions[${j}]`,
|
|
1916
|
+
message: `"${action}" requires ${companions.map((c) => `"${c}"`).join(' or ')} in the same grant: a mutate verb reaches the record and its history, so it conveys nothing without read`,
|
|
1940
1917
|
});
|
|
1941
1918
|
});
|
|
1942
1919
|
if (errors.length > 0) {
|
|
@@ -1944,58 +1921,82 @@ export class Stack {
|
|
|
1944
1921
|
}
|
|
1945
1922
|
}
|
|
1946
1923
|
async seedSystemTypes() {
|
|
1947
|
-
await this.defineType(
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1924
|
+
await this.defineType({
|
|
1925
|
+
id: `${SYSTEM_TYPES.CONFIG}@1`,
|
|
1926
|
+
name: 'Config',
|
|
1927
|
+
schema: {
|
|
1928
|
+
entityId: { kind: 'string', required: true },
|
|
1929
|
+
// Optional passthrough app metadata — see ConfigContent.timezone.
|
|
1930
|
+
timezone: { kind: 'string' },
|
|
1931
|
+
},
|
|
1951
1932
|
});
|
|
1952
|
-
await this.defineType(
|
|
1953
|
-
|
|
1954
|
-
name:
|
|
1955
|
-
|
|
1933
|
+
await this.defineType({
|
|
1934
|
+
id: `${SYSTEM_TYPES.ENTITY}@1`,
|
|
1935
|
+
name: 'Entity',
|
|
1936
|
+
schema: {
|
|
1937
|
+
did: { kind: 'string', required: true },
|
|
1938
|
+
name: { kind: 'string', required: true },
|
|
1939
|
+
handle: { kind: 'string' },
|
|
1940
|
+
},
|
|
1956
1941
|
});
|
|
1957
|
-
await this.defineType(
|
|
1958
|
-
|
|
1959
|
-
name:
|
|
1960
|
-
|
|
1961
|
-
|
|
1942
|
+
await this.defineType({
|
|
1943
|
+
id: `${SYSTEM_TYPES.APP}@1`,
|
|
1944
|
+
name: 'App',
|
|
1945
|
+
schema: {
|
|
1946
|
+
appId: { kind: 'string', required: true },
|
|
1947
|
+
name: { kind: 'string', required: true },
|
|
1948
|
+
version: { kind: 'string' },
|
|
1949
|
+
did: { kind: 'string' },
|
|
1950
|
+
},
|
|
1962
1951
|
});
|
|
1963
|
-
await this.defineType(
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1952
|
+
await this.defineType({
|
|
1953
|
+
id: `${SYSTEM_TYPES.GROUP}@1`,
|
|
1954
|
+
name: 'Group',
|
|
1955
|
+
schema: {
|
|
1956
|
+
name: { kind: 'string', required: true },
|
|
1957
|
+
handle: { kind: 'string' },
|
|
1958
|
+
stackUrl: { kind: 'string' },
|
|
1959
|
+
},
|
|
1967
1960
|
});
|
|
1968
|
-
await this.defineType(
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
kind
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
kind:
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1961
|
+
await this.defineType({
|
|
1962
|
+
id: `${SYSTEM_TYPES.GRANT}@1`,
|
|
1963
|
+
name: 'Grant',
|
|
1964
|
+
schema: {
|
|
1965
|
+
typeId: { kind: 'string', required: true },
|
|
1966
|
+
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
1967
|
+
// Required, and closed: a Grant's reach is spelled by its `grantee`,
|
|
1968
|
+
// so a record arriving without one is refused here rather than read
|
|
1969
|
+
// as a grant to every authenticated entity. The arms differ in which
|
|
1970
|
+
// fields they carry, which a schema cannot express — evaluation reads
|
|
1971
|
+
// the `kind` and confers nothing on one it does not recognize.
|
|
1972
|
+
// See docs/spec/access-control.md § Type-level grants.
|
|
1973
|
+
grantee: {
|
|
1974
|
+
kind: 'object',
|
|
1975
|
+
required: true,
|
|
1976
|
+
properties: {
|
|
1977
|
+
kind: { kind: 'string', required: true },
|
|
1978
|
+
entityId: { kind: 'string' },
|
|
1979
|
+
groupId: { kind: 'string' },
|
|
1980
|
+
role: { kind: 'string' },
|
|
1981
|
+
},
|
|
1985
1982
|
},
|
|
1986
1983
|
},
|
|
1987
1984
|
});
|
|
1988
|
-
await this.defineType(
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1985
|
+
await this.defineType({
|
|
1986
|
+
id: `${SYSTEM_TYPES.ATTACHMENT}@1`,
|
|
1987
|
+
name: 'Attachment',
|
|
1988
|
+
schema: {
|
|
1989
|
+
// Deliberately `string`, not `file-ref`: referencesFileId matching
|
|
1990
|
+
// (deleteAttachment()/collectAttachmentGarbage()'s reference scan) is
|
|
1991
|
+
// schema-driven, so a `file-ref` fileId here would make every
|
|
1992
|
+
// metadata record its own file's reference — nothing would ever be
|
|
1993
|
+
// deletable or collectible. See docs/spec/attachments.md § Deleting
|
|
1994
|
+
// attachments / Garbage collection.
|
|
1995
|
+
fileId: { kind: 'string', required: true },
|
|
1996
|
+
mimeType: { kind: 'string', required: true },
|
|
1997
|
+
size: { kind: 'number', required: true },
|
|
1998
|
+
filename: { kind: 'string' },
|
|
1999
|
+
},
|
|
1999
2000
|
});
|
|
2000
2001
|
}
|
|
2001
2002
|
/**
|
|
@@ -2013,16 +2014,15 @@ export class Stack {
|
|
|
2013
2014
|
* The options every version-bumping adapter write carries: the `ifVersion`
|
|
2014
2015
|
* precondition, the prior-state snapshot that has to land in the same
|
|
2015
2016
|
* atomic write, and who to attribute the change to. Taken together so a
|
|
2016
|
-
* new mutating verb cannot quietly omit one — `
|
|
2017
|
-
* whose absence reads as an undelegated write.
|
|
2017
|
+
* new mutating verb cannot quietly omit one — the actor's `principalId`
|
|
2018
|
+
* most of all, whose absence reads as an undelegated write.
|
|
2018
2019
|
* See docs/spec/versioning.md § Version history.
|
|
2019
2020
|
*/
|
|
2020
2021
|
writeOptions(existing, opts) {
|
|
2021
2022
|
return {
|
|
2022
|
-
|
|
2023
|
+
ifVersion: opts.ifVersion,
|
|
2023
2024
|
snapshot: this.buildVersionSnapshot(existing),
|
|
2024
|
-
|
|
2025
|
-
updatedVia: opts.updatedVia,
|
|
2025
|
+
actor: normalizeActor(opts.actor),
|
|
2026
2026
|
};
|
|
2027
2027
|
}
|
|
2028
2028
|
/**
|
|
@@ -2040,9 +2040,8 @@ export class Stack {
|
|
|
2040
2040
|
typeId: record.typeId,
|
|
2041
2041
|
content: record.content,
|
|
2042
2042
|
updatedAt: record.updatedAt,
|
|
2043
|
-
...(record.
|
|
2043
|
+
...(record.createdBy && { createdBy: record.createdBy }),
|
|
2044
2044
|
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
2045
|
-
...(record.updatedVia && { updatedVia: record.updatedVia }),
|
|
2046
2045
|
};
|
|
2047
2046
|
}
|
|
2048
2047
|
}
|