@haverstack/core 0.39.0 → 0.41.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 +79 -16
- package/dist/access.js +1 -1
- package/dist/access.js.map +1 -1
- package/dist/adapter-entry.d.ts +2 -1
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +2 -1
- package/dist/adapter-entry.js.map +1 -1
- package/dist/changes.js +3 -3
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +2 -3
- package/dist/combine.js.map +1 -1
- package/dist/errors.d.ts +16 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +23 -2
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +9 -1
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +34 -4
- package/dist/grants.js.map +1 -1
- package/dist/identity-bindings.d.ts.map +1 -1
- package/dist/identity-bindings.js +3 -0
- package/dist/identity-bindings.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +144 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +223 -0
- package/dist/install.js.map +1 -0
- package/dist/query-validation.d.ts +34 -1
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +101 -1
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +15 -1
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +33 -3
- package/dist/record-changes.js.map +1 -1
- package/dist/record-id.js +1 -1
- package/dist/record-id.js.map +1 -1
- package/dist/schema.d.ts +6 -7
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +33 -10
- package/dist/schema.js.map +1 -1
- package/dist/scoped-stack.d.ts +50 -23
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +202 -129
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack.d.ts +182 -45
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +481 -192
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +28 -11
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +42 -37
- package/dist/testing.js.map +1 -1
- package/dist/type-handle.d.ts +157 -0
- package/dist/type-handle.d.ts.map +1 -0
- package/dist/type-handle.js +131 -0
- package/dist/type-handle.js.map +1 -0
- package/dist/types.d.ts +91 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +7 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +131 -7
- package/dist/validate.js.map +1 -1
- package/dist/wire-body.d.ts +19 -3
- package/dist/wire-body.d.ts.map +1 -1
- package/dist/wire-body.js +99 -2
- package/dist/wire-body.js.map +1 -1
- package/dist/wire-entry.d.ts +2 -2
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +4 -4
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-request.d.ts +18 -1
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +33 -1
- package/dist/wire-request.js.map +1 -1
- package/package.json +1 -1
package/dist/stack.js
CHANGED
|
@@ -14,22 +14,24 @@
|
|
|
14
14
|
* Apps should never talk to a StackAdapter directly.
|
|
15
15
|
*/
|
|
16
16
|
import { generateId, generateIdForTimestamp } from './id.js';
|
|
17
|
-
import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas,
|
|
18
|
-
import { validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
|
|
17
|
+
import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, familyIdProblem, } from './schema.js';
|
|
18
|
+
import { dropAbsentFields, validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
|
|
19
19
|
import { applyMergePatch } from './merge.js';
|
|
20
|
-
import { hasGroupAdmin,
|
|
20
|
+
import { hasGroupAdmin, validatePermissions } from './access.js';
|
|
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
24
|
import { UseAfterCloseError, InvalidAdapterError, StackConflictError, StackMigrationError, StackNotFoundError, StackBadRequestError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
|
|
25
|
-
import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidJournalQuery, assertValidSort, assertAuthorityAssociations, assertDataAssociations, filtersContent,
|
|
26
|
-
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee,
|
|
25
|
+
import { assertQueryCapabilities, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidJournalQuery, assertValidVersionsQuery, assertValidSort, normalizeSort, assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, filtersContent, validateAssociations, } from './query-validation.js';
|
|
26
|
+
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, grantReach, matchesGrantTarget, validateGrantTarget, validateGrantee, validateGrantBaseId, grantCoversGrantee, loadGrantRecords, } from './grants.js';
|
|
27
27
|
import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
|
|
28
|
+
import { claimedFamilies, familyStanding, grantIsRequest, installAppLink, installGrantLink, installReader, linkedIds, namespaceOf, ownTypeIds, planFingerprint, sameRequest, snapshotManifest, validateInstall, INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, } from './install.js';
|
|
28
29
|
import { assertAttachmentSize, assertContentSize } from './limits.js';
|
|
29
30
|
import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
|
|
30
31
|
import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
|
|
31
|
-
import {
|
|
32
|
+
import { applyAssociationEdits, associationDelta, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
|
|
32
33
|
import { ScopedStack, scopeToken } from './scoped-stack.js';
|
|
34
|
+
import { isTypeHandle, typedCreate, typedGet, typedMutate, typedQuery, typedSubscribe, } from './type-handle.js';
|
|
33
35
|
// -------------------------------------------------------
|
|
34
36
|
// Supporting types
|
|
35
37
|
// -------------------------------------------------------
|
|
@@ -235,8 +237,10 @@ export class Stack {
|
|
|
235
237
|
* anything beyond additive evolution throws StackSchemaDriftError. See
|
|
236
238
|
* docs/spec/data-model.md § Schema drift detection.
|
|
237
239
|
*/
|
|
238
|
-
async defineType({ id, name, schema, migratesFrom }) {
|
|
240
|
+
async defineType({ id, name, schema: declared, migratesFrom, }) {
|
|
239
241
|
this.assertOpen();
|
|
242
|
+
// Only read below; the readonly view exists so a handle's literal fits.
|
|
243
|
+
const schema = declared;
|
|
240
244
|
const parsed = parseTypeId(id);
|
|
241
245
|
if (!parsed) {
|
|
242
246
|
throw new StackBadRequestError(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
|
|
@@ -373,15 +377,25 @@ export class Stack {
|
|
|
373
377
|
* the only way disk state changes version. Sweeps soft-deleted records
|
|
374
378
|
* too, validates each result before writing, and aborts on the first
|
|
375
379
|
* validation failure. See docs/spec/data-model.md § Type migrations.
|
|
380
|
+
*
|
|
381
|
+
* `sweep: 'listed'` is for a contained app over `APIAdapter`, which can
|
|
382
|
+
* neither enumerate unlisted records nor read a deleted one's content:
|
|
383
|
+
* it migrates live, listed records and counts the deleted ones it passed
|
|
384
|
+
* over in `skipped`. See docs/spec/apps.md § Migrating an installed app's types.
|
|
376
385
|
*/
|
|
377
|
-
async migrateAll(baseId) {
|
|
386
|
+
async migrateAll(baseId, opts = {}) {
|
|
378
387
|
this.assertOpen();
|
|
388
|
+
const problem = familyIdProblem(baseId, 'migrateAll');
|
|
389
|
+
if (problem)
|
|
390
|
+
throw new StackValidationError([{ path: 'baseId', message: problem }]);
|
|
379
391
|
const types = await this.adapter.listTypes();
|
|
380
392
|
const familyTypeIds = types.filter((t) => t.baseId === baseId).map((t) => t.id);
|
|
381
393
|
if (familyTypeIds.length === 0) {
|
|
382
394
|
throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`);
|
|
383
395
|
}
|
|
396
|
+
const listedOnly = opts.sweep === 'listed';
|
|
384
397
|
let migrated = 0;
|
|
398
|
+
let skipped = 0;
|
|
385
399
|
for (const typeId of familyTypeIds) {
|
|
386
400
|
const latestId = this.latestTypeId(typeId);
|
|
387
401
|
if (typeId === latestId)
|
|
@@ -396,11 +410,15 @@ export class Stack {
|
|
|
396
410
|
let cursor;
|
|
397
411
|
do {
|
|
398
412
|
const result = await this.adapter.queryRecords({
|
|
399
|
-
filter: { typeId, includeDeleted: true, includeUnlisted:
|
|
413
|
+
filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly },
|
|
400
414
|
limit: 100,
|
|
401
415
|
cursor,
|
|
402
416
|
});
|
|
403
417
|
for (const record of result.records) {
|
|
418
|
+
if (listedOnly && record.deletedAt) {
|
|
419
|
+
skipped++;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
404
422
|
// Same checked path commitMigration() takes — a migration
|
|
405
423
|
// function is no more entitled to move a DID binding or repoint
|
|
406
424
|
// an attachment than a request body is. No ifVersion: a batch
|
|
@@ -411,28 +429,18 @@ export class Stack {
|
|
|
411
429
|
cursor = result.cursor ?? undefined;
|
|
412
430
|
} while (cursor);
|
|
413
431
|
}
|
|
414
|
-
return { migrated };
|
|
432
|
+
return { migrated, skipped };
|
|
415
433
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
* Create a new record. Validates content against the type's schema.
|
|
421
|
-
* `_group` records get their author stamped as the first `admin` roster
|
|
422
|
-
* association here — the single stamping site for both Stack.create()
|
|
423
|
-
* and ScopedStack.create(). `opts.createdAt`/`updatedAt` let a caller
|
|
424
|
-
* backdate an imported record's clock fields — unconditionally here;
|
|
425
|
-
* ScopedStack.create() forwards to this same method, but only reaches
|
|
426
|
-
* this far with them when the requester is the stack owner acting alone
|
|
427
|
-
* — see BackdatableCreateRecordOptions and docs/spec/data-model.md §
|
|
428
|
-
* Record IDs.
|
|
429
|
-
*/
|
|
430
|
-
async create(typeId, content, opts = {}) {
|
|
434
|
+
async create(typeIdOrHandle, input, opts = {}) {
|
|
435
|
+
if (isTypeHandle(typeIdOrHandle))
|
|
436
|
+
return typedCreate(this, typeIdOrHandle, input, opts);
|
|
437
|
+
const typeId = typeIdOrHandle;
|
|
431
438
|
this.assertOpen();
|
|
432
439
|
const type = await this.getTypeCached(typeId);
|
|
433
440
|
if (!type) {
|
|
434
441
|
throw new StackBadRequestError(`Unknown type: "${typeId}". Call defineType() first.`);
|
|
435
442
|
}
|
|
443
|
+
const content = dropAbsentFields(input, type.schema);
|
|
436
444
|
// Copied, never aliased: an import loop that reuses one Date across rows
|
|
437
445
|
// (`d.setTime(...)` per record) would otherwise retro-edit every record
|
|
438
446
|
// it had already written, with no version bump and no change event.
|
|
@@ -453,6 +461,8 @@ export class Stack {
|
|
|
453
461
|
...validateContentKeys(content),
|
|
454
462
|
...validateContent(content, type.schema),
|
|
455
463
|
...validateGrantee(typeId, content),
|
|
464
|
+
...validateGrantBaseId(typeId, content),
|
|
465
|
+
...validateInstall(typeId, content),
|
|
456
466
|
...validateAssociations(opts.permissions, 'permissions'),
|
|
457
467
|
...validatePermissions(opts.permissions),
|
|
458
468
|
...validateAssociations(opts.associations),
|
|
@@ -564,35 +574,33 @@ export class Stack {
|
|
|
564
574
|
}
|
|
565
575
|
return record;
|
|
566
576
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
async get(id, opts = {}) {
|
|
577
|
+
async get(idOrHandle, idOrOpts = {}) {
|
|
578
|
+
if (isTypeHandle(idOrHandle))
|
|
579
|
+
return typedGet(this, idOrHandle, idOrOpts);
|
|
580
|
+
const id = idOrHandle;
|
|
581
|
+
const opts = idOrOpts;
|
|
573
582
|
this.assertOpen();
|
|
574
583
|
const record = await this.adapter.getRecord(id);
|
|
575
584
|
if (!record)
|
|
576
585
|
return null;
|
|
586
|
+
if (record.deletedAt && !opts.includeDeleted)
|
|
587
|
+
return null;
|
|
577
588
|
return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
|
|
578
589
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
* against the record's *current* stored type; `typeId` never changes here.
|
|
587
|
-
* See docs/spec/data-model.md § Mutations.
|
|
588
|
-
*/
|
|
589
|
-
async mutate(id, changes, opts = {}) {
|
|
590
|
+
async mutate(first, second, third, fourth = {}) {
|
|
591
|
+
if (isTypeHandle(first)) {
|
|
592
|
+
return typedMutate(this, first, second, third, fourth);
|
|
593
|
+
}
|
|
594
|
+
const id = first;
|
|
595
|
+
const changes = second;
|
|
596
|
+
const opts = (third ?? {});
|
|
590
597
|
this.assertOpen();
|
|
591
598
|
assertNonEmptyChangeSet(changes);
|
|
592
599
|
const existing = await this.adapter.getRecord(id);
|
|
593
600
|
if (!existing) {
|
|
594
601
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
595
602
|
}
|
|
603
|
+
this.refuseIfDeleted(existing);
|
|
596
604
|
// Checked before validation, so a caller that lost the race learns its
|
|
597
605
|
// version is stale rather than that its patch is bad, and before the
|
|
598
606
|
// no-op short-circuit below, so a precondition is never satisfied by a
|
|
@@ -641,13 +649,11 @@ export class Stack {
|
|
|
641
649
|
this.announce(change, updated);
|
|
642
650
|
return updated;
|
|
643
651
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
async patchContent(id, patch, opts = {}) {
|
|
650
|
-
return this.mutate(id, { contentPatch: patch }, opts);
|
|
652
|
+
async patchContent(first, second, third, fourth) {
|
|
653
|
+
if (isTypeHandle(first)) {
|
|
654
|
+
return this.mutate(first, second, { contentPatch: third }, fourth);
|
|
655
|
+
}
|
|
656
|
+
return this.mutate(first, { contentPatch: second }, third);
|
|
651
657
|
}
|
|
652
658
|
/**
|
|
653
659
|
* Every check a change set owes before anything is written, in one pass:
|
|
@@ -700,6 +706,8 @@ export class Stack {
|
|
|
700
706
|
const contentErrors = [
|
|
701
707
|
...validateContent(merged, type.schema),
|
|
702
708
|
...validateGrantee(existing.typeId, merged),
|
|
709
|
+
...validateGrantBaseId(existing.typeId, merged),
|
|
710
|
+
...validateInstall(existing.typeId, merged),
|
|
703
711
|
];
|
|
704
712
|
if (contentErrors.length > 0)
|
|
705
713
|
throw new StackValidationError(contentErrors);
|
|
@@ -746,7 +754,7 @@ export class Stack {
|
|
|
746
754
|
'least one `admin` relationship association. Name the incoming admin in the same write.');
|
|
747
755
|
}
|
|
748
756
|
/**
|
|
749
|
-
* Add
|
|
757
|
+
* Add associations to a record — no-bump and no snapshot, per the
|
|
750
758
|
* StackClient declaration above.
|
|
751
759
|
*
|
|
752
760
|
* An association the record already holds, saying the same thing, is a
|
|
@@ -756,120 +764,86 @@ export class Stack {
|
|
|
756
764
|
* file. What a re-point overwrote is kept only by the journal, on that
|
|
757
765
|
* entry's `repoint`. See docs/spec/journal.md § The entry.
|
|
758
766
|
*/
|
|
759
|
-
async associate(id,
|
|
760
|
-
this.
|
|
761
|
-
assertDataAssociations([association], 'associate()');
|
|
762
|
-
const errors = validateAssociation(association);
|
|
763
|
-
if (errors.length > 0)
|
|
764
|
-
throw new StackValidationError(errors);
|
|
765
|
-
const existing = await this.adapter.getRecord(id);
|
|
766
|
-
if (!existing) {
|
|
767
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
768
|
-
}
|
|
769
|
-
if ((existing.associations ?? []).some((a) => associationIdentical(a, association))) {
|
|
770
|
-
return existing;
|
|
771
|
-
}
|
|
772
|
-
const replaced = (existing.associations ?? []).find((a) => associationEqual(a, association));
|
|
773
|
-
await this.checkAttachmentAssociationPointers([association]);
|
|
774
|
-
const change = new PendingChange('associate', {
|
|
775
|
-
actor: normalizeActor(opts.actor),
|
|
776
|
-
// A re-point carries what it overwrote: nothing else retains the
|
|
777
|
-
// attachmentRecordId that association held.
|
|
778
|
-
associations: [
|
|
779
|
-
replaced ? { op: 'repoint', association, previous: replaced } : { op: 'add', association },
|
|
780
|
-
],
|
|
781
|
-
});
|
|
782
|
-
const updated = await this.adapter.associate(id, association, { journal: change.journal });
|
|
783
|
-
this.announce(change, updated);
|
|
784
|
-
return updated;
|
|
767
|
+
async associate(id, associations, opts = {}) {
|
|
768
|
+
return this.amendAssociations(id, associations.map((association) => ({ op: 'add', association })), opts, 'associate()');
|
|
785
769
|
}
|
|
786
770
|
/**
|
|
787
|
-
* Remove
|
|
788
|
-
* label and payload; a no-op
|
|
789
|
-
* association by identity only, since an attachment's
|
|
771
|
+
* Remove associations from a record — see associate(). Matched by kind,
|
|
772
|
+
* label and payload; an element not found is a no-op. The emitted event
|
|
773
|
+
* names each association by identity only, since an attachment's
|
|
790
774
|
* `attachmentRecordId` describes nothing current once it is gone; the
|
|
791
775
|
* journal is where it survives.
|
|
792
776
|
*/
|
|
793
|
-
async dissociate(id,
|
|
794
|
-
this.
|
|
795
|
-
assertDataAssociations([association], 'dissociate()');
|
|
796
|
-
const errors = validateAssociation(association);
|
|
797
|
-
if (errors.length > 0)
|
|
798
|
-
throw new StackValidationError(errors);
|
|
799
|
-
const existing = await this.adapter.getRecord(id);
|
|
800
|
-
if (!existing) {
|
|
801
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
802
|
-
}
|
|
803
|
-
const matched = (existing.associations ?? []).find((a) => associationEqual(a, association));
|
|
804
|
-
if (!matched) {
|
|
805
|
-
return existing;
|
|
806
|
-
}
|
|
807
|
-
// Removing anything else cannot take the roster to zero, so only an
|
|
808
|
-
// admin entry is worth deriving the post-state for. Derived rather than
|
|
809
|
-
// counted so this path and the change set's reach the invariant through
|
|
810
|
-
// the same helper, asking the same question of the same shape of list.
|
|
811
|
-
if (isGroupAdminAssociation(association)) {
|
|
812
|
-
this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
|
|
813
|
-
}
|
|
814
|
-
const change = new PendingChange('dissociate', {
|
|
815
|
-
actor: normalizeActor(opts.actor),
|
|
816
|
-
// In full, annotation included — what makes a removal as undoable
|
|
817
|
-
// from the log as a re-point is. The frame this becomes still names
|
|
818
|
-
// identity only. See docs/spec/journal.md § The entry.
|
|
819
|
-
associations: [{ op: 'remove', previous: matched }],
|
|
820
|
-
});
|
|
821
|
-
const updated = await this.adapter.dissociate(id, association, { journal: change.journal });
|
|
822
|
-
this.announce(change, updated);
|
|
823
|
-
return updated;
|
|
777
|
+
async dissociate(id, associations, opts = {}) {
|
|
778
|
+
return this.amendAssociations(id, associations.map((association) => ({ op: 'remove', association })), opts, 'dissociate()');
|
|
824
779
|
}
|
|
825
780
|
/**
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
781
|
+
* The atomic form of associate() and dissociate(): one list of adds and
|
|
782
|
+
* removes, one adapter write, one journal entry. The journal records each
|
|
783
|
+
* element in full — a remove with its annotation, a re-point with what it
|
|
784
|
+
* overwrote — so the entry is as undoable as a single-element one.
|
|
785
|
+
* See docs/spec/data-model.md § Mutations.
|
|
831
786
|
*/
|
|
832
|
-
async
|
|
787
|
+
async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
|
|
833
788
|
this.assertOpen();
|
|
834
|
-
|
|
835
|
-
const errors = validateAssociation(permission, 'permission');
|
|
836
|
-
if (errors.length > 0)
|
|
837
|
-
throw new StackValidationError(errors);
|
|
789
|
+
assertAssociationEdits(changes, surface, 'data');
|
|
838
790
|
const existing = await this.requireRecord(id);
|
|
839
|
-
|
|
840
|
-
|
|
791
|
+
this.refuseIfDeleted(existing);
|
|
792
|
+
const current = existing.associations ?? [];
|
|
793
|
+
const next = applyAssociationEdits(current, changes);
|
|
794
|
+
const delta = associationDelta(current, next);
|
|
795
|
+
if (delta.length === 0)
|
|
841
796
|
return existing;
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
797
|
+
// Only a roster can lose its last admin, and only the post-state says so.
|
|
798
|
+
this.assertGroupAdminRemains(existing, next);
|
|
799
|
+
await this.checkAttachmentAssociationPointers(changes.flatMap((c) => (c.op === 'add' ? [c.association] : [])), current);
|
|
800
|
+
const change = new PendingChange([
|
|
801
|
+
...(delta.some((c) => c.op !== 'remove') ? ['associate'] : []),
|
|
802
|
+
...(delta.some((c) => c.op === 'remove') ? ['dissociate'] : []),
|
|
803
|
+
], { actor: normalizeActor(opts.actor), associations: delta });
|
|
804
|
+
const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
|
|
848
805
|
this.announce(change, updated);
|
|
849
806
|
return updated;
|
|
850
807
|
}
|
|
851
808
|
/**
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
809
|
+
* Extend who reaches a record. The spelling that survives two admins
|
|
810
|
+
* sharing a record at once: a `permissions` key write replaces the whole
|
|
811
|
+
* set, so the later of two concurrent ones drops what the earlier
|
|
812
|
+
* granted. No-bump, like associate(); an element the record already
|
|
813
|
+
* carries is a no-op. `write` never implies `read` — name both.
|
|
814
|
+
*/
|
|
815
|
+
async grantAccess(id, permissions, opts = {}) {
|
|
816
|
+
return this.amendAccess(id, permissions.map((association) => ({ op: 'add', association })), opts, 'grantAccess()');
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Withdraw elements of who reaches a record — see grantAccess() for why
|
|
820
|
+
* this is a verb rather than a key write. An element the record does not
|
|
821
|
+
* carry is a no-op. Returns the record as it now stands.
|
|
822
|
+
*/
|
|
823
|
+
async revokeAccess(id, permissions, opts = {}) {
|
|
824
|
+
return this.amendAccess(id, permissions.map((association) => ({ op: 'remove', association })), opts, 'revokeAccess()');
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* The atomic form of grantAccess() and revokeAccess(). The
|
|
828
|
+
* write-implies-read invariant is asked once, of the set the whole list
|
|
829
|
+
* produces. See docs/spec/access-control.md § Write implies read.
|
|
855
830
|
*/
|
|
856
|
-
async
|
|
831
|
+
async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
|
|
857
832
|
this.assertOpen();
|
|
858
|
-
|
|
859
|
-
const errors = validateAssociation(permission, 'permission');
|
|
860
|
-
if (errors.length > 0)
|
|
861
|
-
throw new StackValidationError(errors);
|
|
833
|
+
assertAssociationEdits(changes, surface, 'authority');
|
|
862
834
|
const existing = await this.requireRecord(id);
|
|
835
|
+
this.refuseIfDeleted(existing);
|
|
863
836
|
const current = existing.permissions ?? [];
|
|
864
|
-
const
|
|
865
|
-
|
|
837
|
+
const next = applyAssociationEdits(current, changes);
|
|
838
|
+
const delta = associationDelta(current, next);
|
|
839
|
+
if (delta.length === 0)
|
|
866
840
|
return existing;
|
|
867
|
-
this.assertPermissionSet(
|
|
841
|
+
this.assertPermissionSet(next);
|
|
868
842
|
const change = new PendingChange('reshare', {
|
|
869
843
|
actor: normalizeActor(opts.actor),
|
|
870
|
-
associations:
|
|
844
|
+
associations: delta,
|
|
871
845
|
});
|
|
872
|
-
const updated = await this.adapter.
|
|
846
|
+
const updated = await this.adapter.amendAssociations(id, changes, { journal: change.journal });
|
|
873
847
|
this.announce(change, updated);
|
|
874
848
|
return updated;
|
|
875
849
|
}
|
|
@@ -882,6 +856,18 @@ export class Stack {
|
|
|
882
856
|
if (errors.length > 0)
|
|
883
857
|
throw new StackValidationError(errors);
|
|
884
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* A soft-deleted record has no current state to edit, so the verbs that
|
|
861
|
+
* edit one refuse it; undelete() and commitMigration() do not call this.
|
|
862
|
+
* Asked after the record is found and, under ScopedStack, after the
|
|
863
|
+
* authority decision. See docs/spec/versioning.md § Mutations are refused,
|
|
864
|
+
* not applied to a tombstone.
|
|
865
|
+
*/
|
|
866
|
+
refuseIfDeleted(record) {
|
|
867
|
+
if (record.deletedAt) {
|
|
868
|
+
throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
885
871
|
/** The record, or the not-found refusal every mutating verb owes. */
|
|
886
872
|
async requireRecord(id) {
|
|
887
873
|
const record = await this.adapter.getRecord(id);
|
|
@@ -1045,19 +1031,17 @@ export class Stack {
|
|
|
1045
1031
|
this.announce(change, undeleted);
|
|
1046
1032
|
return undeleted;
|
|
1047
1033
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
* docs/spec/data-model.md § Queries.
|
|
1053
|
-
*/
|
|
1054
|
-
async query(query = {}) {
|
|
1034
|
+
async query(first = {}, typedQueryArg) {
|
|
1035
|
+
if (isTypeHandle(first))
|
|
1036
|
+
return typedQuery(this, first, typedQueryArg);
|
|
1037
|
+
const query = first;
|
|
1055
1038
|
this.assertOpen();
|
|
1056
1039
|
const { presentAt, filter, limit: rawLimit, ...rest } = query;
|
|
1057
1040
|
assertQueryCapabilities(filter, this.adapter.capabilities);
|
|
1058
1041
|
assertValidSort(query.sort);
|
|
1059
1042
|
assertSortCapability(query.sort, this.adapter.capabilities);
|
|
1060
1043
|
assertValidAssociationFilters(filter);
|
|
1044
|
+
assertValidBaseIdFilter(filter);
|
|
1061
1045
|
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
|
|
1062
1046
|
const resolvedFilter = await this.resolveBaseIdFilter(filter);
|
|
1063
1047
|
if (resolvedFilter === EMPTY_FAMILY) {
|
|
@@ -1065,6 +1049,7 @@ export class Stack {
|
|
|
1065
1049
|
}
|
|
1066
1050
|
const result = await this.adapter.queryRecords({
|
|
1067
1051
|
...rest,
|
|
1052
|
+
...(query.sort && { sort: normalizeSort(query.sort) }),
|
|
1068
1053
|
...(resolvedFilter !== undefined && { filter: resolvedFilter }),
|
|
1069
1054
|
...(limit !== undefined && { limit }),
|
|
1070
1055
|
});
|
|
@@ -1095,9 +1080,10 @@ export class Stack {
|
|
|
1095
1080
|
// -------------------------------------------------------
|
|
1096
1081
|
// Versions
|
|
1097
1082
|
// -------------------------------------------------------
|
|
1098
|
-
async getVersions(id) {
|
|
1083
|
+
async getVersions(id, query = {}) {
|
|
1099
1084
|
this.assertOpen();
|
|
1100
|
-
|
|
1085
|
+
assertValidVersionsQuery(query);
|
|
1086
|
+
return this.adapter.getVersions(id, query);
|
|
1101
1087
|
}
|
|
1102
1088
|
/**
|
|
1103
1089
|
* A record's change journal, oldest first — what moved, who moved it, and
|
|
@@ -1129,6 +1115,7 @@ export class Stack {
|
|
|
1129
1115
|
if (!existing) {
|
|
1130
1116
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1131
1117
|
}
|
|
1118
|
+
this.refuseIfDeleted(existing);
|
|
1132
1119
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1133
1120
|
const target = await this.adapter.getVersion(id, version);
|
|
1134
1121
|
if (!target) {
|
|
@@ -1141,6 +1128,8 @@ export class Stack {
|
|
|
1141
1128
|
const errors = [
|
|
1142
1129
|
...validateContent(target.content, type.schema),
|
|
1143
1130
|
...validateGrantee(target.typeId, target.content),
|
|
1131
|
+
...validateGrantBaseId(target.typeId, target.content),
|
|
1132
|
+
...validateInstall(target.typeId, target.content),
|
|
1144
1133
|
];
|
|
1145
1134
|
if (errors.length > 0) {
|
|
1146
1135
|
throw new StackValidationError(errors);
|
|
@@ -1206,17 +1195,20 @@ export class Stack {
|
|
|
1206
1195
|
* can cross type families; family-crossing is exactly what the checks
|
|
1207
1196
|
* below care about.
|
|
1208
1197
|
*/
|
|
1209
|
-
async commitMigrationChecked(existing, toTypeId,
|
|
1198
|
+
async commitMigrationChecked(existing, toTypeId, input, opts = {}) {
|
|
1210
1199
|
const id = existing.id;
|
|
1211
1200
|
const type = await this.getTypeCached(toTypeId);
|
|
1212
1201
|
if (!type) {
|
|
1213
1202
|
throw new StackBadRequestError(`Unknown type: "${toTypeId}". Call defineType() first.`);
|
|
1214
1203
|
}
|
|
1204
|
+
const content = dropAbsentFields(input, type.schema);
|
|
1215
1205
|
const errors = [
|
|
1216
1206
|
...validateReservedKeys(content),
|
|
1217
1207
|
...validateContentKeys(content),
|
|
1218
1208
|
...validateContent(content, type.schema),
|
|
1219
1209
|
...validateGrantee(toTypeId, content),
|
|
1210
|
+
...validateGrantBaseId(toTypeId, content),
|
|
1211
|
+
...validateInstall(toTypeId, content),
|
|
1220
1212
|
];
|
|
1221
1213
|
if (errors.length > 0) {
|
|
1222
1214
|
throw new StackValidationError(errors);
|
|
@@ -1667,22 +1659,15 @@ export class Stack {
|
|
|
1667
1659
|
}
|
|
1668
1660
|
return { deletedFileIds, reclaimedBytes };
|
|
1669
1661
|
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
* every record by other means; `ScopedStack.subscribe()` is the filtered
|
|
1677
|
-
* view. See docs/spec/events.md.
|
|
1678
|
-
*
|
|
1679
|
-
* Async so that a remote stack can resolve once its feed is live, which
|
|
1680
|
-
* makes subscribe-then-query the gap-free startup order everywhere. A
|
|
1681
|
-
* local stack is live immediately.
|
|
1682
|
-
*/
|
|
1683
|
-
async subscribe(handler, opts = {}) {
|
|
1662
|
+
async subscribe(first, second, third) {
|
|
1663
|
+
if (isTypeHandle(first)) {
|
|
1664
|
+
return typedSubscribe(this, first, second, third);
|
|
1665
|
+
}
|
|
1666
|
+
const handler = first;
|
|
1667
|
+
const opts = (second ?? {});
|
|
1684
1668
|
this.assertOpen();
|
|
1685
1669
|
assertSinceUsable(opts.since, this.relaysChanges);
|
|
1670
|
+
assertValidBaseIdFilter(opts.filter);
|
|
1686
1671
|
const unsubscribe = this.changes.subscribe(handler, opts);
|
|
1687
1672
|
let stopRelay;
|
|
1688
1673
|
try {
|
|
@@ -1785,16 +1770,15 @@ export class Stack {
|
|
|
1785
1770
|
* needs, not the suffix that looks narrowest. See
|
|
1786
1771
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1787
1772
|
*
|
|
1788
|
-
* `
|
|
1789
|
-
*
|
|
1790
|
-
* docs/spec/access-control.md § Type-level grants.
|
|
1773
|
+
* `baseId` names the whole family and is refused when it carries an
|
|
1774
|
+
* `@version` suffix. See docs/spec/access-control.md § Type-level grants.
|
|
1791
1775
|
*/
|
|
1792
|
-
async grantType(
|
|
1776
|
+
async grantType(baseId, grant) {
|
|
1793
1777
|
this.assertOpen();
|
|
1794
1778
|
validateGrantTarget(grant.grantee);
|
|
1795
|
-
this.checkGrantValid(
|
|
1779
|
+
this.checkGrantValid(baseId, grant.actions);
|
|
1796
1780
|
return this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1797
|
-
|
|
1781
|
+
baseId,
|
|
1798
1782
|
actions: grant.actions,
|
|
1799
1783
|
grantee: grant.grantee,
|
|
1800
1784
|
});
|
|
@@ -1834,7 +1818,7 @@ export class Stack {
|
|
|
1834
1818
|
allowDefault: true,
|
|
1835
1819
|
allowGroup: true,
|
|
1836
1820
|
groupRoles,
|
|
1837
|
-
resolveRecord: (id) => this.get(id),
|
|
1821
|
+
resolveRecord: (id) => this.get(id, { includeDeleted: true }),
|
|
1838
1822
|
});
|
|
1839
1823
|
if (covers)
|
|
1840
1824
|
result.push(r);
|
|
@@ -1843,7 +1827,7 @@ export class Stack {
|
|
|
1843
1827
|
}
|
|
1844
1828
|
/**
|
|
1845
1829
|
* The inverse of grantType(): soft-deletes the _grant records on
|
|
1846
|
-
* `
|
|
1830
|
+
* `baseId`'s family matching `grant`, at the same granularity
|
|
1847
1831
|
* grantType() writes — the grantee is matched whole, role included, and
|
|
1848
1832
|
* the actions exactly. A soft delete like any other — the owner can
|
|
1849
1833
|
* undelete a revocation.
|
|
@@ -1854,10 +1838,13 @@ export class Stack {
|
|
|
1854
1838
|
* revocation has to stay safe. See
|
|
1855
1839
|
* docs/spec/access-control.md § Listing and revoking.
|
|
1856
1840
|
*/
|
|
1857
|
-
async revokeType(
|
|
1841
|
+
async revokeType(baseId, grant) {
|
|
1858
1842
|
this.assertOpen();
|
|
1859
1843
|
validateGrantTarget(grant.grantee);
|
|
1860
|
-
const
|
|
1844
|
+
const problem = familyIdProblem(baseId, 'revokeType');
|
|
1845
|
+
if (problem)
|
|
1846
|
+
throw new StackValidationError([{ path: 'baseId', message: problem }]);
|
|
1847
|
+
const familyId = baseId;
|
|
1861
1848
|
const actionSet = new Set(grant.actions);
|
|
1862
1849
|
const all = await loadGrantRecords((q) => this.query(q));
|
|
1863
1850
|
const matches = all.filter((r) => {
|
|
@@ -1878,32 +1865,313 @@ export class Stack {
|
|
|
1878
1865
|
return matches;
|
|
1879
1866
|
}
|
|
1880
1867
|
// -------------------------------------------------------
|
|
1868
|
+
// App installs
|
|
1869
|
+
// -------------------------------------------------------
|
|
1870
|
+
/**
|
|
1871
|
+
* What applying `manifest` for the key `did` would change, for the owner
|
|
1872
|
+
* to review before installApp(). Writes nothing. Refuses outright what no
|
|
1873
|
+
* approval could make valid: a type outside the app's own namespace and
|
|
1874
|
+
* the commons, a request the grant rules refuse, or a `did` already
|
|
1875
|
+
* registered to a different app. See docs/spec/apps.md § Plan, then apply.
|
|
1876
|
+
*/
|
|
1877
|
+
async planInstall(submitted, opts) {
|
|
1878
|
+
this.assertOpen();
|
|
1879
|
+
const { did } = opts;
|
|
1880
|
+
const manifest = snapshotManifest(submitted);
|
|
1881
|
+
this.checkManifest(manifest, did);
|
|
1882
|
+
const installs = await this.loadInstalls();
|
|
1883
|
+
const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null;
|
|
1884
|
+
const ownVersions = ownTypeIds(manifest);
|
|
1885
|
+
const ownFamilies = new Set(ownVersions.map(baseIdOf));
|
|
1886
|
+
const card = await this.findAppCard(did);
|
|
1887
|
+
if (card && card.content.appId !== manifest.appId) {
|
|
1888
|
+
throw new StackConflictError(`${did} is registered to "${card.content.appId}", not "${manifest.appId}"`);
|
|
1889
|
+
}
|
|
1890
|
+
const claimed = existing ? claimedFamilies(existing.content) : new Set();
|
|
1891
|
+
const defined = new Set(existing?.content.defines ?? []);
|
|
1892
|
+
const prior = existing?.content.requests ?? [];
|
|
1893
|
+
const foreignRequests = [];
|
|
1894
|
+
for (const r of manifest.requests) {
|
|
1895
|
+
const standing = familyStanding(r.baseId, manifest.appId);
|
|
1896
|
+
if (standing === 'own')
|
|
1897
|
+
continue;
|
|
1898
|
+
const owner = standing === 'foreign'
|
|
1899
|
+
? namespaceOf(r.baseId)
|
|
1900
|
+
: standing === 'commons'
|
|
1901
|
+
? 'commons'
|
|
1902
|
+
: 'system';
|
|
1903
|
+
foreignRequests.push({ ...r, owner });
|
|
1904
|
+
}
|
|
1905
|
+
const typeChanges = [];
|
|
1906
|
+
for (const t of manifest.types) {
|
|
1907
|
+
const current = await this.getTypeCached(t.id);
|
|
1908
|
+
if (!current)
|
|
1909
|
+
typeChanges.push({ id: t.id, change: 'new' });
|
|
1910
|
+
else if (current.schemaHash !== (await hashSchema(t.schema))) {
|
|
1911
|
+
// Refused here, not left to defineType(): installApp() defines types one
|
|
1912
|
+
// by one, so a drift found there leaves the earlier ones written.
|
|
1913
|
+
const violations = diffSchemas(current.schema, t.schema);
|
|
1914
|
+
if (violations.length > 0)
|
|
1915
|
+
throw new StackSchemaDriftError(t.id, violations);
|
|
1916
|
+
typeChanges.push({ id: t.id, change: 'schema' });
|
|
1917
|
+
}
|
|
1918
|
+
else if (current.name !== t.name)
|
|
1919
|
+
typeChanges.push({ id: t.id, change: 'name' });
|
|
1920
|
+
}
|
|
1921
|
+
const linkedKeys = [];
|
|
1922
|
+
for (const id of existing ? linkedIds(existing, INSTALL_APP_LABEL) : []) {
|
|
1923
|
+
const key = (await this.get(id))?.content?.did;
|
|
1924
|
+
if (typeof key === 'string')
|
|
1925
|
+
linkedKeys.push(key);
|
|
1926
|
+
}
|
|
1927
|
+
return {
|
|
1928
|
+
manifest,
|
|
1929
|
+
did,
|
|
1930
|
+
existing,
|
|
1931
|
+
newFamilies: [...ownFamilies].filter((f) => !claimed.has(f)),
|
|
1932
|
+
newVersions: ownVersions.filter((id) => !defined.has(id)),
|
|
1933
|
+
requestsAdded: manifest.requests.filter((r) => !prior.some((p) => sameRequest(p, r))),
|
|
1934
|
+
requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))),
|
|
1935
|
+
foreignRequests,
|
|
1936
|
+
typeChanges,
|
|
1937
|
+
newKey: !existing ||
|
|
1938
|
+
!card ||
|
|
1939
|
+
card.deletedAt !== undefined ||
|
|
1940
|
+
!linkedIds(existing, INSTALL_APP_LABEL).includes(card.id),
|
|
1941
|
+
linkedKeys,
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Apply an approved plan: define the manifest's types, register the
|
|
1946
|
+
* key's `_app` card if it has none, write the `_install` Record, and
|
|
1947
|
+
* bring every linked key's grants to exactly what the manifest requests.
|
|
1948
|
+
* Refuses a plan the stack has moved on from with StackConflictError, so
|
|
1949
|
+
* what is applied is what was approved. Reinstalls a soft-deleted
|
|
1950
|
+
* install. See docs/spec/apps.md § Plan, then apply.
|
|
1951
|
+
*/
|
|
1952
|
+
async installApp(plan) {
|
|
1953
|
+
this.assertOpen();
|
|
1954
|
+
const fresh = await this.planInstall(plan.manifest, { did: plan.did });
|
|
1955
|
+
if (planFingerprint(fresh) !== planFingerprint(plan)) {
|
|
1956
|
+
throw new StackConflictError(`The stack changed since the install of "${plan.manifest.appId}" was planned; plan it again`);
|
|
1957
|
+
}
|
|
1958
|
+
const { manifest, did, existing } = fresh;
|
|
1959
|
+
for (const type of manifest.types)
|
|
1960
|
+
await this.defineType(type);
|
|
1961
|
+
const card = await this.ensureAppCard(manifest, did);
|
|
1962
|
+
const defines = [...new Set([...(existing?.content.defines ?? []), ...ownTypeIds(manifest)])];
|
|
1963
|
+
const requests = manifest.requests.map((r) => ({
|
|
1964
|
+
baseId: r.baseId,
|
|
1965
|
+
actions: [...r.actions],
|
|
1966
|
+
}));
|
|
1967
|
+
let install;
|
|
1968
|
+
if (!existing) {
|
|
1969
|
+
install = await this.create(`${SYSTEM_TYPES.INSTALL}@1`, {
|
|
1970
|
+
appId: manifest.appId,
|
|
1971
|
+
name: manifest.name,
|
|
1972
|
+
...(manifest.version !== undefined && { version: manifest.version }),
|
|
1973
|
+
defines,
|
|
1974
|
+
requests,
|
|
1975
|
+
}, { associations: [installAppLink(card.id)] });
|
|
1976
|
+
}
|
|
1977
|
+
else {
|
|
1978
|
+
const current = existing.deletedAt ? await this.undelete(existing.id) : existing;
|
|
1979
|
+
install = await this.patchContent(existing.id, { name: manifest.name, version: manifest.version ?? null, defines, requests }, { ifVersion: current.version });
|
|
1980
|
+
if (!linkedIds(install, INSTALL_APP_LABEL).includes(card.id)) {
|
|
1981
|
+
install = await this.associate(install.id, [installAppLink(card.id)]);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
return (await this.reconcileInstallGrants(install));
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Withdraw every grant an install produced and soft-delete it. The app's
|
|
1988
|
+
* records and `_app` cards stay: the data is the owner's, and the cards
|
|
1989
|
+
* are what its records' attribution resolves through. Returns the
|
|
1990
|
+
* install's tombstone. See docs/spec/apps.md § Uninstalling.
|
|
1991
|
+
*/
|
|
1992
|
+
async uninstallApp(appId) {
|
|
1993
|
+
this.assertOpen();
|
|
1994
|
+
const install = (await this.loadInstalls()).find((r) => r.content.appId === appId && !r.deletedAt);
|
|
1995
|
+
if (!install)
|
|
1996
|
+
throw new StackNotFoundError(`No install for "${appId}"`);
|
|
1997
|
+
const edits = [];
|
|
1998
|
+
for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) {
|
|
1999
|
+
if (await this.get(id))
|
|
2000
|
+
await this.delete(id);
|
|
2001
|
+
edits.push({ op: 'remove', association: installGrantLink(id) });
|
|
2002
|
+
}
|
|
2003
|
+
if (edits.length > 0)
|
|
2004
|
+
await this.amendAssociations(install.id, edits);
|
|
2005
|
+
const { record } = await this.deleteAndReturn(install.id);
|
|
2006
|
+
return record;
|
|
2007
|
+
}
|
|
2008
|
+
/** A manifest's own shape, and every request held to grantType()'s rules. */
|
|
2009
|
+
checkManifest(manifest, did) {
|
|
2010
|
+
const errors = [];
|
|
2011
|
+
if (typeof did !== 'string' || !did.startsWith('did:')) {
|
|
2012
|
+
errors.push({ path: 'did', message: 'Expected a DID' });
|
|
2013
|
+
}
|
|
2014
|
+
if (typeof manifest.appId !== 'string' || manifest.appId === '') {
|
|
2015
|
+
errors.push({ path: 'appId', message: 'Expected a non-empty appId' });
|
|
2016
|
+
}
|
|
2017
|
+
if (typeof manifest.name !== 'string' || manifest.name === '') {
|
|
2018
|
+
errors.push({ path: 'name', message: 'Expected a non-empty name' });
|
|
2019
|
+
}
|
|
2020
|
+
const seen = new Set();
|
|
2021
|
+
manifest.types.forEach((t, i) => {
|
|
2022
|
+
if (seen.has(t.id)) {
|
|
2023
|
+
errors.push({ path: `types[${i}].id`, message: `"${t.id}" is listed more than once` });
|
|
2024
|
+
}
|
|
2025
|
+
seen.add(t.id);
|
|
2026
|
+
const schemaPath = `types[${i}].schema`;
|
|
2027
|
+
const shapeErrors = validateSchemaShape(t.schema, schemaPath);
|
|
2028
|
+
errors.push(...shapeErrors);
|
|
2029
|
+
if (shapeErrors.length === 0) {
|
|
2030
|
+
const schema = t.schema;
|
|
2031
|
+
for (const e of validateSchemaReservedNames(schema)) {
|
|
2032
|
+
errors.push({ ...e, path: `${schemaPath}.${e.path}` });
|
|
2033
|
+
}
|
|
2034
|
+
validateSchemaFieldNames(schema, schemaPath, errors);
|
|
2035
|
+
}
|
|
2036
|
+
const parsed = parseTypeId(t.id);
|
|
2037
|
+
if (!parsed) {
|
|
2038
|
+
errors.push({ path: `types[${i}].id`, message: 'Expected a versioned TypeId' });
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
const standing = familyStanding(parsed.baseId, manifest.appId);
|
|
2042
|
+
if (standing === 'system' || standing === 'foreign') {
|
|
2043
|
+
errors.push({
|
|
2044
|
+
path: `types[${i}].id`,
|
|
2045
|
+
message: standing === 'system'
|
|
2046
|
+
? `"${parsed.baseId}" is a system type; no app can define it`
|
|
2047
|
+
: `"${parsed.baseId}" is outside the namespace "${manifest.appId}"; request access to it instead of defining it`,
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
if (errors.length > 0)
|
|
2053
|
+
throw new StackValidationError(errors);
|
|
2054
|
+
for (const r of manifest.requests)
|
|
2055
|
+
this.checkGrantValid(r.baseId, r.actions);
|
|
2056
|
+
}
|
|
2057
|
+
/** Every `_install` Record, deleted and unlisted included — a deleted install keeps its claims. */
|
|
2058
|
+
async loadInstalls() {
|
|
2059
|
+
const records = await queryAllPages((q) => this.query(q), {
|
|
2060
|
+
filter: { baseId: SYSTEM_TYPES.INSTALL, includeDeleted: true, includeUnlisted: true },
|
|
2061
|
+
});
|
|
2062
|
+
return records;
|
|
2063
|
+
}
|
|
2064
|
+
/** The `_app` card claiming `did`, deleted and unlisted included. */
|
|
2065
|
+
findAppCard(did) {
|
|
2066
|
+
return findFirstMatch((q) => this.query(q), {
|
|
2067
|
+
filter: {
|
|
2068
|
+
baseId: SYSTEM_TYPES.APP,
|
|
2069
|
+
includeDeleted: true,
|
|
2070
|
+
includeUnlisted: true,
|
|
2071
|
+
...(filtersContent(this.capabilities) && { content: { did } }),
|
|
2072
|
+
},
|
|
2073
|
+
}, (r) => r.content.did === did);
|
|
2074
|
+
}
|
|
2075
|
+
/** The key's `_app` card, created or undeleted as needed. */
|
|
2076
|
+
async ensureAppCard(manifest, did) {
|
|
2077
|
+
const card = await this.findAppCard(did);
|
|
2078
|
+
if (!card) {
|
|
2079
|
+
return this.create(`${SYSTEM_TYPES.APP}@1`, {
|
|
2080
|
+
appId: manifest.appId,
|
|
2081
|
+
name: manifest.name,
|
|
2082
|
+
...(manifest.version !== undefined && { version: manifest.version }),
|
|
2083
|
+
did,
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
return card.deletedAt ? this.undelete(card.id) : card;
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Make the grants linked to `install` exactly its `requests`, once per
|
|
2090
|
+
* live linked key: withdraw any that no longer match, write any missing,
|
|
2091
|
+
* and drop links to grants that are gone.
|
|
2092
|
+
*/
|
|
2093
|
+
async reconcileInstallGrants(install) {
|
|
2094
|
+
const { requests } = install.content;
|
|
2095
|
+
const dids = [];
|
|
2096
|
+
for (const id of linkedIds(install, INSTALL_APP_LABEL)) {
|
|
2097
|
+
const did = (await this.get(id))?.content?.did;
|
|
2098
|
+
if (typeof did === 'string')
|
|
2099
|
+
dids.push(did);
|
|
2100
|
+
}
|
|
2101
|
+
const edits = [];
|
|
2102
|
+
const held = [];
|
|
2103
|
+
for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) {
|
|
2104
|
+
const grant = await this.get(id);
|
|
2105
|
+
const content = grant?.content;
|
|
2106
|
+
const wanted = content !== undefined &&
|
|
2107
|
+
baseIdOf(grant.typeId) === SYSTEM_TYPES.GRANT &&
|
|
2108
|
+
dids.some((did) => requests.some((r) => grantIsRequest(content, r, did)));
|
|
2109
|
+
if (wanted) {
|
|
2110
|
+
held.push(content);
|
|
2111
|
+
continue;
|
|
2112
|
+
}
|
|
2113
|
+
if (grant)
|
|
2114
|
+
await this.delete(id);
|
|
2115
|
+
edits.push({ op: 'remove', association: installGrantLink(id) });
|
|
2116
|
+
}
|
|
2117
|
+
for (const did of dids) {
|
|
2118
|
+
for (const r of requests) {
|
|
2119
|
+
if (held.some((g) => grantIsRequest(g, r, did)))
|
|
2120
|
+
continue;
|
|
2121
|
+
const grant = await this.grantType(r.baseId, {
|
|
2122
|
+
actions: r.actions,
|
|
2123
|
+
grantee: { kind: 'entity', entityId: did },
|
|
2124
|
+
});
|
|
2125
|
+
held.push(grant.content);
|
|
2126
|
+
edits.push({ op: 'add', association: installGrantLink(grant.id) });
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
let result = edits.length > 0 ? await this.amendAssociations(install.id, edits) : install;
|
|
2130
|
+
// Each linked key may read its own install, which is how an app learns
|
|
2131
|
+
// what was approved; a key of this app no longer linked may not.
|
|
2132
|
+
// See docs/spec/apps.md § Over the wire.
|
|
2133
|
+
const appKeys = new Set((await queryAllPages((q) => this.query(q), {
|
|
2134
|
+
filter: { baseId: SYSTEM_TYPES.APP, includeDeleted: true, includeUnlisted: true },
|
|
2135
|
+
}))
|
|
2136
|
+
.map((r) => r.content)
|
|
2137
|
+
.filter((c) => c.appId === install.content.appId)
|
|
2138
|
+
.map((c) => c.did));
|
|
2139
|
+
const readerOf = (p) => p.kind === 'permission' && p.label === 'read' && p.grantee.kind === 'entity'
|
|
2140
|
+
? p.grantee.entityId
|
|
2141
|
+
: null;
|
|
2142
|
+
const current = (result.permissions ?? []).map(readerOf);
|
|
2143
|
+
const access = [
|
|
2144
|
+
...dids
|
|
2145
|
+
.filter((did) => !current.includes(did))
|
|
2146
|
+
.map((entityId) => ({ op: 'add', association: installReader(entityId) })),
|
|
2147
|
+
...current
|
|
2148
|
+
.filter((did) => did !== null && appKeys.has(did) && !dids.includes(did))
|
|
2149
|
+
.map((entityId) => ({ op: 'remove', association: installReader(entityId) })),
|
|
2150
|
+
];
|
|
2151
|
+
if (access.length > 0)
|
|
2152
|
+
result = await this.amendAccess(install.id, access);
|
|
2153
|
+
return result;
|
|
2154
|
+
}
|
|
2155
|
+
// -------------------------------------------------------
|
|
1881
2156
|
// Private helpers
|
|
1882
2157
|
// -------------------------------------------------------
|
|
1883
2158
|
/**
|
|
1884
|
-
* Actions must be known GrantAction values
|
|
1885
|
-
*
|
|
1886
|
-
* docs/spec/access-control.md § Type-level grants.
|
|
2159
|
+
* Actions must be known GrantAction values; the target is held to the
|
|
2160
|
+
* same rule every `_grant` write meets — see validateGrantBaseId().
|
|
1887
2161
|
*/
|
|
1888
|
-
checkGrantValid(
|
|
2162
|
+
checkGrantValid(baseId, actions) {
|
|
1889
2163
|
const errors = [];
|
|
1890
2164
|
actions.forEach((action, j) => {
|
|
1891
2165
|
if (!GRANT_ACTION_SET.has(action)) {
|
|
1892
2166
|
errors.push({ path: `actions[${j}]`, message: `Unknown grant action "${action}"` });
|
|
1893
2167
|
}
|
|
1894
2168
|
});
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
message: `"${typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
|
|
1899
|
-
});
|
|
2169
|
+
const problem = familyIdProblem(baseId, 'grantType');
|
|
2170
|
+
if (problem) {
|
|
2171
|
+
errors.push({ path: 'baseId', message: problem });
|
|
1900
2172
|
}
|
|
1901
|
-
else
|
|
1902
|
-
|
|
1903
|
-
errors.push({
|
|
1904
|
-
path: 'typeId',
|
|
1905
|
-
message: `Cannot grant on "${baseIdOf(typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
|
|
1906
|
-
});
|
|
2173
|
+
else {
|
|
2174
|
+
errors.push(...validateGrantBaseId(`${SYSTEM_TYPES.GRANT}@1`, { baseId }));
|
|
1907
2175
|
}
|
|
1908
2176
|
actions.forEach((action, j) => {
|
|
1909
2177
|
if (grantConveys(actions, action))
|
|
@@ -1913,7 +2181,7 @@ export class Stack {
|
|
|
1913
2181
|
return;
|
|
1914
2182
|
errors.push({
|
|
1915
2183
|
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`,
|
|
2184
|
+
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. Name both in one grant: actions: [${companions.map((c) => `'${c}'`).join(' | ')}, '${action}']`,
|
|
1917
2185
|
});
|
|
1918
2186
|
});
|
|
1919
2187
|
if (errors.length > 0) {
|
|
@@ -1962,7 +2230,7 @@ export class Stack {
|
|
|
1962
2230
|
id: `${SYSTEM_TYPES.GRANT}@1`,
|
|
1963
2231
|
name: 'Grant',
|
|
1964
2232
|
schema: {
|
|
1965
|
-
|
|
2233
|
+
baseId: { kind: 'string', required: true },
|
|
1966
2234
|
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
1967
2235
|
// Required, and closed: a Grant's reach is spelled by its `grantee`,
|
|
1968
2236
|
// so a record arriving without one is refused here rather than read
|
|
@@ -1998,6 +2266,27 @@ export class Stack {
|
|
|
1998
2266
|
filename: { kind: 'string' },
|
|
1999
2267
|
},
|
|
2000
2268
|
});
|
|
2269
|
+
await this.defineType({
|
|
2270
|
+
id: `${SYSTEM_TYPES.INSTALL}@1`,
|
|
2271
|
+
name: 'Install',
|
|
2272
|
+
schema: {
|
|
2273
|
+
appId: { kind: 'string', required: true },
|
|
2274
|
+
name: { kind: 'string', required: true },
|
|
2275
|
+
version: { kind: 'string' },
|
|
2276
|
+
defines: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
2277
|
+
requests: {
|
|
2278
|
+
kind: 'array',
|
|
2279
|
+
required: true,
|
|
2280
|
+
items: {
|
|
2281
|
+
kind: 'object',
|
|
2282
|
+
properties: {
|
|
2283
|
+
baseId: { kind: 'string', required: true },
|
|
2284
|
+
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
2285
|
+
},
|
|
2286
|
+
},
|
|
2287
|
+
},
|
|
2288
|
+
},
|
|
2289
|
+
});
|
|
2001
2290
|
}
|
|
2002
2291
|
/**
|
|
2003
2292
|
* Fast-fail for the ifVersion precondition using the already-fetched
|