@haverstack/core 0.36.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 +78 -33
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +119 -89
- 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 +162 -154
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +307 -290
- 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
|
|
@@ -959,27 +950,45 @@ export class Stack {
|
|
|
959
950
|
* destroys the only rows naming them, so a caller who means to erase the
|
|
960
951
|
* bytes too has nowhere else to read the argument from afterwards. See
|
|
961
952
|
* docs/spec/attachments.md § A purge strands the bytes it referenced.
|
|
953
|
+
*
|
|
954
|
+
* deleteAndReturn() is this plus the record itself; reach for that
|
|
955
|
+
* instead when the caller needs the record too — building a response
|
|
956
|
+
* body from a separate read beforehand is exactly the race and the extra
|
|
957
|
+
* round trip it exists to remove.
|
|
962
958
|
*/
|
|
963
959
|
async delete(id, opts = {}) {
|
|
960
|
+
const { referencedFileIds } = await this.deleteAndReturn(id, opts);
|
|
961
|
+
return { referencedFileIds };
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* delete(), reporting the record it acted on alongside the files it
|
|
965
|
+
* stranded: as it stood immediately before destruction for a
|
|
966
|
+
* purge, or as the resulting tombstone for a soft delete. Both are
|
|
967
|
+
* captured inside the same write that destroys or tombstones the record,
|
|
968
|
+
* never a read beforehand — a read-then-delete would leave a window
|
|
969
|
+
* where a concurrent write can land, get destroyed or overwritten by
|
|
970
|
+
* this call, and never appear in the record this returns.
|
|
971
|
+
*/
|
|
972
|
+
async deleteAndReturn(id, opts = {}) {
|
|
964
973
|
this.assertOpen();
|
|
965
974
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
966
975
|
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
967
976
|
}
|
|
968
|
-
if (opts.
|
|
977
|
+
if (opts.purge) {
|
|
969
978
|
// The adapter hands back what it destroyed, captured inside the same
|
|
970
979
|
// write: a read here instead would race the delete, and afterwards
|
|
971
980
|
// there is nothing left to read. Null means there was no record, so
|
|
972
981
|
// nothing was purged and nothing is announced.
|
|
973
|
-
const change = new PendingChange('
|
|
982
|
+
const change = new PendingChange('purge', { actor: normalizeActor(opts.actor) });
|
|
974
983
|
const purged = await this.adapter.deleteRecord(id, {
|
|
975
|
-
|
|
976
|
-
|
|
984
|
+
purge: true,
|
|
985
|
+
ifVersion: opts.ifVersion,
|
|
977
986
|
journal: change.journal,
|
|
978
987
|
});
|
|
979
988
|
if (!purged)
|
|
980
|
-
return { referencedFileIds: [] };
|
|
989
|
+
return { record: null, referencedFileIds: [] };
|
|
981
990
|
this.announce(change, purged);
|
|
982
|
-
return { referencedFileIds: await this.referencedFileIds(purged) };
|
|
991
|
+
return { record: purged, referencedFileIds: await this.referencedFileIds(purged) };
|
|
983
992
|
}
|
|
984
993
|
const existing = await this.adapter.getRecord(id);
|
|
985
994
|
if (!existing) {
|
|
@@ -989,15 +998,15 @@ export class Stack {
|
|
|
989
998
|
// A tombstone's references stand — undelete() must find its
|
|
990
999
|
// attachments intact — so nothing is stranded and nothing is reported.
|
|
991
1000
|
if (existing.deletedAt)
|
|
992
|
-
return { referencedFileIds: [] };
|
|
993
|
-
const change = new PendingChange('delete', { actor:
|
|
1001
|
+
return { record: existing, referencedFileIds: [] };
|
|
1002
|
+
const change = new PendingChange('delete', { actor: normalizeActor(opts.actor) });
|
|
994
1003
|
const deleted = await this.adapter.deleteRecord(id, {
|
|
995
1004
|
...this.writeOptions(existing, opts),
|
|
996
1005
|
journal: change.journal,
|
|
997
1006
|
});
|
|
998
1007
|
if (deleted)
|
|
999
1008
|
this.announce(change, deleted);
|
|
1000
|
-
return { referencedFileIds: [] };
|
|
1009
|
+
return { record: deleted ?? existing, referencedFileIds: [] };
|
|
1001
1010
|
}
|
|
1002
1011
|
/**
|
|
1003
1012
|
* The files a record references, by the same definition
|
|
@@ -1015,7 +1024,7 @@ export class Stack {
|
|
|
1015
1024
|
}
|
|
1016
1025
|
/**
|
|
1017
1026
|
* Reverse a soft delete. Idempotent — undeleting a record that isn't
|
|
1018
|
-
* deleted returns it unchanged.
|
|
1027
|
+
* deleted returns it unchanged. Purged records are gone, so this
|
|
1019
1028
|
* throws StackNotFoundError for them just like any other missing record.
|
|
1020
1029
|
* Snapshots and bumps version, same as delete().
|
|
1021
1030
|
*/
|
|
@@ -1028,7 +1037,7 @@ export class Stack {
|
|
|
1028
1037
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1029
1038
|
if (!existing.deletedAt)
|
|
1030
1039
|
return existing;
|
|
1031
|
-
const change = new PendingChange('undelete', { actor:
|
|
1040
|
+
const change = new PendingChange('undelete', { actor: normalizeActor(opts.actor) });
|
|
1032
1041
|
const undeleted = await this.adapter.undeleteRecord(id, {
|
|
1033
1042
|
...this.writeOptions(existing, opts),
|
|
1034
1043
|
journal: change.journal,
|
|
@@ -1048,7 +1057,7 @@ export class Stack {
|
|
|
1048
1057
|
assertQueryCapabilities(filter, this.adapter.capabilities);
|
|
1049
1058
|
assertValidSort(query.sort);
|
|
1050
1059
|
assertSortCapability(query.sort, this.adapter.capabilities);
|
|
1051
|
-
|
|
1060
|
+
assertValidAssociationFilters(filter);
|
|
1052
1061
|
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
|
|
1053
1062
|
const resolvedFilter = await this.resolveBaseIdFilter(filter);
|
|
1054
1063
|
if (resolvedFilter === EMPTY_FAMILY) {
|
|
@@ -1127,7 +1136,7 @@ export class Stack {
|
|
|
1127
1136
|
}
|
|
1128
1137
|
const type = await this.getTypeCached(target.typeId);
|
|
1129
1138
|
if (!type) {
|
|
1130
|
-
throw new
|
|
1139
|
+
throw new StackBadRequestError(`Unknown type: "${target.typeId}"`);
|
|
1131
1140
|
}
|
|
1132
1141
|
const errors = [
|
|
1133
1142
|
...validateContent(target.content, type.schema),
|
|
@@ -1150,7 +1159,10 @@ export class Stack {
|
|
|
1150
1159
|
// A restore adds no containment edge and takes none away, so there is
|
|
1151
1160
|
// no cycle for it to close and nothing for the destination checks to
|
|
1152
1161
|
// gate. See docs/spec/versioning.md § Restore semantics.
|
|
1153
|
-
const change = new PendingChange('restore', {
|
|
1162
|
+
const change = new PendingChange('restore', {
|
|
1163
|
+
actor: normalizeActor(opts.actor),
|
|
1164
|
+
previousTypeId: existing.typeId,
|
|
1165
|
+
});
|
|
1154
1166
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1155
1167
|
...this.writeOptions(existing, opts),
|
|
1156
1168
|
journal: change.journal,
|
|
@@ -1198,7 +1210,7 @@ export class Stack {
|
|
|
1198
1210
|
const id = existing.id;
|
|
1199
1211
|
const type = await this.getTypeCached(toTypeId);
|
|
1200
1212
|
if (!type) {
|
|
1201
|
-
throw new
|
|
1213
|
+
throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
|
|
1202
1214
|
}
|
|
1203
1215
|
const errors = [
|
|
1204
1216
|
...validateReservedKeys(content),
|
|
@@ -1209,7 +1221,7 @@ export class Stack {
|
|
|
1209
1221
|
if (errors.length > 0) {
|
|
1210
1222
|
throw new StackValidationError(errors);
|
|
1211
1223
|
}
|
|
1212
|
-
assertContentSize(content, this.
|
|
1224
|
+
assertContentSize(content, this.capabilities.limits.contentBytes, 'Content');
|
|
1213
1225
|
const fromFamily = baseIdOf(existing.typeId);
|
|
1214
1226
|
const toFamily = baseIdOf(toTypeId);
|
|
1215
1227
|
const existingContent = existing.content;
|
|
@@ -1239,7 +1251,10 @@ export class Stack {
|
|
|
1239
1251
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1240
1252
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1241
1253
|
}
|
|
1242
|
-
const change = new PendingChange('migrate', {
|
|
1254
|
+
const change = new PendingChange('migrate', {
|
|
1255
|
+
actor: normalizeActor(opts.actor),
|
|
1256
|
+
previousTypeId: existing.typeId,
|
|
1257
|
+
});
|
|
1243
1258
|
const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
|
|
1244
1259
|
...this.writeOptions(existing, opts),
|
|
1245
1260
|
journal: change.journal,
|
|
@@ -1299,8 +1314,8 @@ export class Stack {
|
|
|
1299
1314
|
}
|
|
1300
1315
|
}
|
|
1301
1316
|
/**
|
|
1302
|
-
* A unique binding field is what a lookup resolves *by* —
|
|
1303
|
-
* `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
|
|
1304
1319
|
* claiming one value would leave that lookup without a single answer, and
|
|
1305
1320
|
* ambiguity is all an impersonating card needs. Enforced here rather than
|
|
1306
1321
|
* by schema, since uniqueness is a property of the set, not of the value.
|
|
@@ -1318,7 +1333,7 @@ export class Stack {
|
|
|
1318
1333
|
baseId: family,
|
|
1319
1334
|
includeDeleted: true,
|
|
1320
1335
|
includeUnlisted: true,
|
|
1321
|
-
...(filtersContent(this.
|
|
1336
|
+
...(filtersContent(this.capabilities) && { content: { [field]: value } }),
|
|
1322
1337
|
},
|
|
1323
1338
|
}, (r) => r.id !== excludeId && r.content[field] === value);
|
|
1324
1339
|
if (clash) {
|
|
@@ -1458,19 +1473,20 @@ export class Stack {
|
|
|
1458
1473
|
}
|
|
1459
1474
|
/**
|
|
1460
1475
|
* Store bytes and create an _attachment@1 metadata record (owner-
|
|
1461
|
-
* attributed, no
|
|
1476
|
+
* attributed, no createdBy), returning that record — `content.fileId`
|
|
1462
1477
|
* addresses the bytes, `id` addresses the metadata. Delegates to the
|
|
1463
1478
|
* adapter's atomic putAttachmentWithMetadata() when implemented, trusting
|
|
1464
1479
|
* the returned record as backend-authoritative; otherwise falls back to
|
|
1465
1480
|
* bytes-then-create(). See docs/spec/wire-format.md § Attachments.
|
|
1466
1481
|
*/
|
|
1467
|
-
async putAttachment(data,
|
|
1482
|
+
async putAttachment(data, opts) {
|
|
1483
|
+
const { mimeType, filename, appId } = opts;
|
|
1468
1484
|
this.assertOpen();
|
|
1469
|
-
assertAttachmentSize(data.byteLength, this.
|
|
1485
|
+
assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
|
|
1470
1486
|
if (this.adapter.putAttachmentWithMetadata) {
|
|
1471
1487
|
// The metadata record is written inside the adapter, so create()
|
|
1472
1488
|
// never sees it and this is the only place it can be announced.
|
|
1473
|
-
const record = await this.adapter.putAttachmentWithMetadata(data,
|
|
1489
|
+
const record = await this.adapter.putAttachmentWithMetadata(data, opts);
|
|
1474
1490
|
// The one emission with no journal half of its own: the far side
|
|
1475
1491
|
// wrote the record, so it appended the entry in that same write —
|
|
1476
1492
|
// the same division saveVersion() follows over this adapter. See
|
|
@@ -1478,7 +1494,7 @@ export class Stack {
|
|
|
1478
1494
|
this.announce(new PendingChange('create'), record);
|
|
1479
1495
|
return record;
|
|
1480
1496
|
}
|
|
1481
|
-
const fileId = await this.adapter.
|
|
1497
|
+
const fileId = await this.adapter.putBlob(data);
|
|
1482
1498
|
return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
|
|
1483
1499
|
fileId,
|
|
1484
1500
|
mimeType,
|
|
@@ -1488,7 +1504,7 @@ export class Stack {
|
|
|
1488
1504
|
}
|
|
1489
1505
|
async getAttachment(fileId) {
|
|
1490
1506
|
this.assertOpen();
|
|
1491
|
-
return this.adapter.
|
|
1507
|
+
return this.adapter.getBlob(fileId);
|
|
1492
1508
|
}
|
|
1493
1509
|
/**
|
|
1494
1510
|
* Every `_attachment` record describing `fileId`, earliest-recorded
|
|
@@ -1509,7 +1525,7 @@ export class Stack {
|
|
|
1509
1525
|
baseId: SYSTEM_TYPES.ATTACHMENT,
|
|
1510
1526
|
includeDeleted: true,
|
|
1511
1527
|
includeUnlisted: true,
|
|
1512
|
-
...(filtersContent(this.
|
|
1528
|
+
...(filtersContent(this.capabilities) && { content: { fileId } }),
|
|
1513
1529
|
},
|
|
1514
1530
|
});
|
|
1515
1531
|
return results
|
|
@@ -1541,7 +1557,7 @@ export class Stack {
|
|
|
1541
1557
|
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
|
|
1542
1558
|
const at = new Date();
|
|
1543
1559
|
for (const record of deletedRecords) {
|
|
1544
|
-
this.announce(new PendingChange('
|
|
1560
|
+
this.announce(new PendingChange('purge', { actor: normalizeActor(opts.actor) }), record, at);
|
|
1545
1561
|
}
|
|
1546
1562
|
}
|
|
1547
1563
|
else {
|
|
@@ -1549,13 +1565,13 @@ export class Stack {
|
|
|
1549
1565
|
}
|
|
1550
1566
|
if (!deletedRecords.length) {
|
|
1551
1567
|
try {
|
|
1552
|
-
await this.adapter.
|
|
1568
|
+
await this.adapter.getBlob(fileId);
|
|
1553
1569
|
}
|
|
1554
1570
|
catch {
|
|
1555
1571
|
throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
|
|
1556
1572
|
}
|
|
1557
1573
|
}
|
|
1558
|
-
await this.adapter.
|
|
1574
|
+
await this.adapter.deleteBlob(fileId);
|
|
1559
1575
|
}
|
|
1560
1576
|
/**
|
|
1561
1577
|
* Non-atomic fallback for adapters that don't implement
|
|
@@ -1567,7 +1583,7 @@ export class Stack {
|
|
|
1567
1583
|
// must find its attachments intact on undelete or relisting. See
|
|
1568
1584
|
// docs/spec/attachments.md § Deleting attachments.
|
|
1569
1585
|
const refResult = await this.query({
|
|
1570
|
-
filter: {
|
|
1586
|
+
filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
|
|
1571
1587
|
limit: 1,
|
|
1572
1588
|
});
|
|
1573
1589
|
if (refResult.records.length > 0) {
|
|
@@ -1577,7 +1593,7 @@ export class Stack {
|
|
|
1577
1593
|
// too — none of it may be left pointing at deleted bytes.
|
|
1578
1594
|
const metaRecords = await this.getAttachmentRecords(fileId);
|
|
1579
1595
|
for (const record of metaRecords) {
|
|
1580
|
-
await this.delete(record.id, {
|
|
1596
|
+
await this.delete(record.id, { purge: true, ...opts });
|
|
1581
1597
|
}
|
|
1582
1598
|
return metaRecords;
|
|
1583
1599
|
}
|
|
@@ -1608,19 +1624,19 @@ export class Stack {
|
|
|
1608
1624
|
}
|
|
1609
1625
|
}
|
|
1610
1626
|
// Bare-bytes orphans: blobs with zero metadata records, only
|
|
1611
|
-
// discoverable if the blob adapter implements
|
|
1627
|
+
// discoverable if the blob adapter implements listBlobs().
|
|
1612
1628
|
const blobByFile = new Map();
|
|
1613
|
-
if (this.adapter.
|
|
1614
|
-
for (const file of await this.adapter.
|
|
1629
|
+
if (this.adapter.listBlobs) {
|
|
1630
|
+
for (const file of await this.adapter.listBlobs()) {
|
|
1615
1631
|
blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
|
|
1616
1632
|
}
|
|
1617
1633
|
}
|
|
1618
1634
|
const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
|
|
1619
|
-
const
|
|
1635
|
+
const deletedFileIds = [];
|
|
1620
1636
|
let reclaimedBytes = 0;
|
|
1621
1637
|
for (const fileId of candidateFileIds) {
|
|
1622
1638
|
const refResult = await this.query({
|
|
1623
|
-
filter: {
|
|
1639
|
+
filter: { referencesFileId: fileId, includeDeleted: true, includeUnlisted: true },
|
|
1624
1640
|
limit: 1,
|
|
1625
1641
|
});
|
|
1626
1642
|
if (refResult.records.length > 0)
|
|
@@ -1632,12 +1648,12 @@ export class Stack {
|
|
|
1632
1648
|
continue;
|
|
1633
1649
|
const size = meta?.size ?? blob?.size ?? 0;
|
|
1634
1650
|
if (dryRun) {
|
|
1635
|
-
|
|
1651
|
+
deletedFileIds.push(fileId);
|
|
1636
1652
|
reclaimedBytes += size;
|
|
1637
1653
|
continue;
|
|
1638
1654
|
}
|
|
1639
1655
|
try {
|
|
1640
|
-
await this.deleteAttachment(fileId, {
|
|
1656
|
+
await this.deleteAttachment(fileId, { actor: opts.actor });
|
|
1641
1657
|
}
|
|
1642
1658
|
catch (err) {
|
|
1643
1659
|
// Raced with a new reference, or another sweep/call already removed
|
|
@@ -1646,10 +1662,10 @@ export class Stack {
|
|
|
1646
1662
|
continue;
|
|
1647
1663
|
throw err;
|
|
1648
1664
|
}
|
|
1649
|
-
|
|
1665
|
+
deletedFileIds.push(fileId);
|
|
1650
1666
|
reclaimedBytes += size;
|
|
1651
1667
|
}
|
|
1652
|
-
return {
|
|
1668
|
+
return { deletedFileIds, reclaimedBytes };
|
|
1653
1669
|
}
|
|
1654
1670
|
// -------------------------------------------------------
|
|
1655
1671
|
// Lifecycle
|
|
@@ -1690,7 +1706,7 @@ export class Stack {
|
|
|
1690
1706
|
*
|
|
1691
1707
|
* The subscription's own filter goes to the relay rather than being
|
|
1692
1708
|
* applied on the way back: the emitter at the far end holds the record,
|
|
1693
|
-
* so it can answer `
|
|
1709
|
+
* so it can answer `createdBy` and `parentId`, which the envelope
|
|
1694
1710
|
* deliberately does not carry. That is also why a relay is opened per
|
|
1695
1711
|
* subscription rather than shared.
|
|
1696
1712
|
*/
|
|
@@ -1711,10 +1727,7 @@ export class Stack {
|
|
|
1711
1727
|
void stop();
|
|
1712
1728
|
};
|
|
1713
1729
|
}
|
|
1714
|
-
/**
|
|
1715
|
-
* Whether changes reach this stack from elsewhere. Public only so
|
|
1716
|
-
* ScopedStack can refuse a scope it cannot honor; not an app-facing API.
|
|
1717
|
-
*/
|
|
1730
|
+
/** Whether changes reach this stack from elsewhere. */
|
|
1718
1731
|
get relaysChanges() {
|
|
1719
1732
|
return typeof this.adapter.subscribeChanges === 'function';
|
|
1720
1733
|
}
|
|
@@ -1749,23 +1762,20 @@ export class Stack {
|
|
|
1749
1762
|
await this.adapter.close?.();
|
|
1750
1763
|
}
|
|
1751
1764
|
}
|
|
1752
|
-
/**
|
|
1753
|
-
* Throws once close() has run. Public only so ScopedStack can gate the
|
|
1754
|
-
* one path it takes to the adapter directly; not an app-facing API.
|
|
1755
|
-
*/
|
|
1765
|
+
/** Throws once close() has run. */
|
|
1756
1766
|
assertOpen() {
|
|
1757
1767
|
if (this.closed)
|
|
1758
|
-
throw new
|
|
1768
|
+
throw new UseAfterCloseError();
|
|
1759
1769
|
}
|
|
1760
1770
|
// -------------------------------------------------------
|
|
1761
1771
|
// Grants
|
|
1762
1772
|
// -------------------------------------------------------
|
|
1763
1773
|
/**
|
|
1764
|
-
* Create _grant
|
|
1765
|
-
*
|
|
1766
|
-
* `
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
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.
|
|
1769
1779
|
*
|
|
1770
1780
|
* Granting an **app** a `-own` action does not contain it the way the
|
|
1771
1781
|
* suffix suggests: when that app acts for someone, `-own` is read as the
|
|
@@ -1775,22 +1785,19 @@ export class Stack {
|
|
|
1775
1785
|
* needs, not the suffix that looks narrowest. See
|
|
1776
1786
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1777
1787
|
*
|
|
1778
|
-
*
|
|
1788
|
+
* `typeOrBaseId` is a versioned TypeId (`"baseId@version"`) or a bare
|
|
1789
|
+
* baseId; either names the whole family. See
|
|
1779
1790
|
* docs/spec/access-control.md § Type-level grants.
|
|
1780
1791
|
*/
|
|
1781
|
-
async
|
|
1792
|
+
async grantType(typeOrBaseId, grant) {
|
|
1782
1793
|
this.assertOpen();
|
|
1783
|
-
validateGrantTarget(
|
|
1784
|
-
this.
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
grantee: target,
|
|
1791
|
-
}));
|
|
1792
|
-
}
|
|
1793
|
-
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
|
+
});
|
|
1794
1801
|
}
|
|
1795
1802
|
/**
|
|
1796
1803
|
* List _grant records. Omit `query` for all grants;
|
|
@@ -1801,12 +1808,12 @@ export class Stack {
|
|
|
1801
1808
|
* (ones naming them, ones naming a group they belong to at a role they
|
|
1802
1809
|
* hold, plus every default grant) — the same resolution hasGrant() uses.
|
|
1803
1810
|
*
|
|
1804
|
-
* Every arm but `entity` answers identity — what `
|
|
1811
|
+
* Every arm but `entity` answers identity — what `grantType()` would have
|
|
1805
1812
|
* written with the same argument. The `entity` arm answers coverage, so
|
|
1806
|
-
* its result is not a preview of what `
|
|
1813
|
+
* its result is not a preview of what `revokeType()` would withdraw.
|
|
1807
1814
|
* See docs/spec/access-control.md § Type-level grants.
|
|
1808
1815
|
*/
|
|
1809
|
-
async
|
|
1816
|
+
async listTypeGrants(query) {
|
|
1810
1817
|
this.assertOpen();
|
|
1811
1818
|
if (query !== undefined)
|
|
1812
1819
|
validateGrantTarget(query, true);
|
|
@@ -1835,90 +1842,78 @@ export class Stack {
|
|
|
1835
1842
|
return result;
|
|
1836
1843
|
}
|
|
1837
1844
|
/**
|
|
1838
|
-
* The inverse of
|
|
1839
|
-
*
|
|
1840
|
-
* writes — the grantee is matched whole, role included
|
|
1841
|
-
* 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.
|
|
1842
1850
|
*
|
|
1843
|
-
* Returns the grants it withdrew, as they stood
|
|
1844
|
-
*
|
|
1845
|
-
* is not an error — a
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
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.
|
|
1848
1856
|
*/
|
|
1849
|
-
async
|
|
1857
|
+
async revokeType(typeOrBaseId, grant) {
|
|
1850
1858
|
this.assertOpen();
|
|
1851
|
-
validateGrantTarget(
|
|
1852
|
-
const
|
|
1859
|
+
validateGrantTarget(grant.grantee);
|
|
1860
|
+
const familyId = baseIdOf(typeOrBaseId);
|
|
1861
|
+
const actionSet = new Set(grant.actions);
|
|
1853
1862
|
const all = await loadGrantRecords((q) => this.query(q));
|
|
1854
|
-
|
|
1855
|
-
const
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
for (const match of matches) {
|
|
1871
|
-
await this.delete(match.id);
|
|
1872
|
-
revoked.push(match);
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
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;
|
|
1876
1879
|
}
|
|
1877
1880
|
// -------------------------------------------------------
|
|
1878
1881
|
// Private helpers
|
|
1879
1882
|
// -------------------------------------------------------
|
|
1880
1883
|
/**
|
|
1881
|
-
*
|
|
1882
|
-
*
|
|
1883
|
-
*
|
|
1884
|
-
* _grant/_config families are refused — see docs/spec/access-control.md
|
|
1885
|
-
* § 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.
|
|
1886
1887
|
*/
|
|
1887
|
-
|
|
1888
|
+
checkGrantValid(typeId, actions) {
|
|
1888
1889
|
const errors = [];
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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")`,
|
|
1897
1899
|
});
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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))
|
|
1903
1910
|
return;
|
|
1904
|
-
|
|
1905
|
-
if (
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
});
|
|
1911
|
-
}
|
|
1912
|
-
g.actions.forEach((action, j) => {
|
|
1913
|
-
if (grantConveys(g.actions, action))
|
|
1914
|
-
return;
|
|
1915
|
-
const companions = READ_COMPANIONS.get(action);
|
|
1916
|
-
if (!companions)
|
|
1917
|
-
return;
|
|
1918
|
-
errors.push({
|
|
1919
|
-
path: `grants[${i}].actions[${j}]`,
|
|
1920
|
-
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`,
|
|
1921
|
-
});
|
|
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`,
|
|
1922
1917
|
});
|
|
1923
1918
|
});
|
|
1924
1919
|
if (errors.length > 0) {
|
|
@@ -1926,58 +1921,82 @@ export class Stack {
|
|
|
1926
1921
|
}
|
|
1927
1922
|
}
|
|
1928
1923
|
async seedSystemTypes() {
|
|
1929
|
-
await this.defineType(
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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
|
+
},
|
|
1933
1932
|
});
|
|
1934
|
-
await this.defineType(
|
|
1935
|
-
|
|
1936
|
-
name:
|
|
1937
|
-
|
|
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
|
+
},
|
|
1938
1941
|
});
|
|
1939
|
-
await this.defineType(
|
|
1940
|
-
|
|
1941
|
-
name:
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
+
},
|
|
1944
1951
|
});
|
|
1945
|
-
await this.defineType(
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
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
|
+
},
|
|
1949
1960
|
});
|
|
1950
|
-
await this.defineType(
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
kind
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
kind:
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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
|
+
},
|
|
1967
1982
|
},
|
|
1968
1983
|
},
|
|
1969
1984
|
});
|
|
1970
|
-
await this.defineType(
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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
|
+
},
|
|
1981
2000
|
});
|
|
1982
2001
|
}
|
|
1983
2002
|
/**
|
|
@@ -1995,16 +2014,15 @@ export class Stack {
|
|
|
1995
2014
|
* The options every version-bumping adapter write carries: the `ifVersion`
|
|
1996
2015
|
* precondition, the prior-state snapshot that has to land in the same
|
|
1997
2016
|
* atomic write, and who to attribute the change to. Taken together so a
|
|
1998
|
-
* new mutating verb cannot quietly omit one — `
|
|
1999
|
-
* 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.
|
|
2000
2019
|
* See docs/spec/versioning.md § Version history.
|
|
2001
2020
|
*/
|
|
2002
2021
|
writeOptions(existing, opts) {
|
|
2003
2022
|
return {
|
|
2004
|
-
|
|
2023
|
+
ifVersion: opts.ifVersion,
|
|
2005
2024
|
snapshot: this.buildVersionSnapshot(existing),
|
|
2006
|
-
|
|
2007
|
-
updatedVia: opts.updatedVia,
|
|
2025
|
+
actor: normalizeActor(opts.actor),
|
|
2008
2026
|
};
|
|
2009
2027
|
}
|
|
2010
2028
|
/**
|
|
@@ -2022,9 +2040,8 @@ export class Stack {
|
|
|
2022
2040
|
typeId: record.typeId,
|
|
2023
2041
|
content: record.content,
|
|
2024
2042
|
updatedAt: record.updatedAt,
|
|
2025
|
-
...(record.
|
|
2043
|
+
...(record.createdBy && { createdBy: record.createdBy }),
|
|
2026
2044
|
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
2027
|
-
...(record.updatedVia && { updatedVia: record.updatedVia }),
|
|
2028
2045
|
};
|
|
2029
2046
|
}
|
|
2030
2047
|
}
|