@haverstack/core 0.11.1 → 0.11.2
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/dist/access.d.ts +9 -1
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +35 -9
- package/dist/access.js.map +1 -1
- package/dist/changes.d.ts +105 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +228 -0
- package/dist/changes.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/stack.d.ts +98 -44
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +497 -69
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +15 -9
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +45 -21
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +158 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/stack.js
CHANGED
|
@@ -17,8 +17,9 @@ import { generateId, isValidIdFormat, idTimestamp } from './id.js';
|
|
|
17
17
|
import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
|
|
18
18
|
import { validateContent, validateReservedKeys } from './validate.js';
|
|
19
19
|
import { applyMergePatch } from './merge.js';
|
|
20
|
-
import { checkAccess, groupRoleFromAssociations } from './access.js';
|
|
20
|
+
import { checkAccess, groupRoleFromAssociations, validatePermissions } from './access.js';
|
|
21
21
|
import { firstRecordedAttachment } from './attachment-download.js';
|
|
22
|
+
import { ChangeEmitter, Subscription, buildEmission, matchesFilter } from './changes.js';
|
|
22
23
|
import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
|
|
23
24
|
// -------------------------------------------------------
|
|
24
25
|
// Supporting types
|
|
@@ -31,6 +32,119 @@ const EMPTY_FAMILY = Symbol('empty-family');
|
|
|
31
32
|
* itself derived from — so this can't drift from the type.
|
|
32
33
|
*/
|
|
33
34
|
const GRANT_ACTION_SET = new Set(GRANT_ACTIONS);
|
|
35
|
+
/**
|
|
36
|
+
* The read actions that make a mutate action coherent, scope for scope: a
|
|
37
|
+
* `-any` verb needs read over the same reach it can mutate, a `-own` verb
|
|
38
|
+
* needs read over its author's own. A grant carrying neither conveys the
|
|
39
|
+
* verb to nobody. `create` has no companion — writing a Record you then
|
|
40
|
+
* cannot read is the drop-box, and it discloses nothing.
|
|
41
|
+
* See docs/spec/access-control.md § Write implies read.
|
|
42
|
+
*/
|
|
43
|
+
const READ_COMPANIONS = new Map([
|
|
44
|
+
['update-own', ['read-own', 'read-any']],
|
|
45
|
+
['delete-own', ['read-own', 'read-any']],
|
|
46
|
+
['update-any', ['read-any']],
|
|
47
|
+
['delete-any', ['read-any']],
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Whether one grant's action list conveys `action`. The companion has to
|
|
51
|
+
* sit in the same `_grant` Record, not merely somewhere in the grantee's
|
|
52
|
+
* set: a grant is revoked whole, so a rule satisfied across two records
|
|
53
|
+
* would let revoking the read one leave a mutate-without-read grant
|
|
54
|
+
* standing — the configuration this rule exists to refuse, arrived at
|
|
55
|
+
* without anyone writing it.
|
|
56
|
+
*/
|
|
57
|
+
function grantConveys(actions, action) {
|
|
58
|
+
if (!actions.includes(action))
|
|
59
|
+
return false;
|
|
60
|
+
const companions = READ_COMPANIONS.get(action);
|
|
61
|
+
return !companions || companions.some((c) => actions.includes(c));
|
|
62
|
+
}
|
|
63
|
+
/** Direct (non-roster) match between a stored _grant's content and a GrantTarget. */
|
|
64
|
+
function matchesGrantTarget(content, target) {
|
|
65
|
+
if (target !== null && typeof target === 'object') {
|
|
66
|
+
// Guarded so an absent groupId can't match the absent granteeGroupId on
|
|
67
|
+
// every entity-targeted and default grant — `undefined === undefined`
|
|
68
|
+
// would otherwise sweep them all into a revoke aimed at one group.
|
|
69
|
+
if (!target.groupId)
|
|
70
|
+
return false;
|
|
71
|
+
return content.granteeGroupId === target.groupId;
|
|
72
|
+
}
|
|
73
|
+
if (target === null) {
|
|
74
|
+
return !content.granteeEntityId && !content.granteeGroupId;
|
|
75
|
+
}
|
|
76
|
+
return content.granteeEntityId === target;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Reject a grant target that names nobody. An empty groupId or entityId is
|
|
80
|
+
* falsy, so a stored record carrying one reads as a *default* grant — every
|
|
81
|
+
* authenticated entity — instead of the target the caller meant. `null` is
|
|
82
|
+
* the only way to say "default".
|
|
83
|
+
*/
|
|
84
|
+
function validateGrantTarget(target) {
|
|
85
|
+
if (target === null)
|
|
86
|
+
return;
|
|
87
|
+
if (typeof target === 'string') {
|
|
88
|
+
if (target.length === 0) {
|
|
89
|
+
throw new StackQueryError('A grant target entityId cannot be empty. Pass null for a default grant.');
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (typeof target.groupId !== 'string' || target.groupId.length === 0) {
|
|
94
|
+
throw new StackQueryError('A group grant target requires a non-empty groupId.');
|
|
95
|
+
}
|
|
96
|
+
// Not a format check: granteeGroupId is a reference to an existing
|
|
97
|
+
// Record, like parentId or an association's recordId, and none of those
|
|
98
|
+
// are parsed either. One that resolves to nothing simply denies.
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether a stored _grant covers `grantee`: a direct DID match, roster
|
|
102
|
+
* membership when `allowGroup`, or a default when `allowDefault`. Presence
|
|
103
|
+
* decides the tier, never truthiness — an empty grantee field names nobody.
|
|
104
|
+
* Module-level so the access checks and listGrants() cannot drift apart.
|
|
105
|
+
* See docs/spec/access-control.md § Type-level grants.
|
|
106
|
+
*/
|
|
107
|
+
async function grantCoversGrantee(c, grantee, opts) {
|
|
108
|
+
const namesEntity = c.granteeEntityId !== undefined;
|
|
109
|
+
const namesGroup = c.granteeGroupId !== undefined;
|
|
110
|
+
if (!namesEntity && !namesGroup)
|
|
111
|
+
return opts.allowDefault;
|
|
112
|
+
if (namesEntity && c.granteeEntityId !== grantee)
|
|
113
|
+
return false;
|
|
114
|
+
if (namesGroup) {
|
|
115
|
+
if (!opts.allowGroup)
|
|
116
|
+
return false;
|
|
117
|
+
if (!c.granteeGroupId)
|
|
118
|
+
return false;
|
|
119
|
+
const role = await resolveGroupRoleMemoized(c.granteeGroupId, grantee, opts.groupRoles, opts.resolveRecord);
|
|
120
|
+
if (role === null)
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* An entity's role on a `_group` roster, memoized in the caller's
|
|
127
|
+
* `groupRoles` map. That map is built per operation and threaded alongside
|
|
128
|
+
* `prefetchedGrants`, so no resolved role outlives the operation that
|
|
129
|
+
* resolved it — removal from a group must never go stale.
|
|
130
|
+
* See docs/spec/access-control.md § Type-level grants.
|
|
131
|
+
*/
|
|
132
|
+
async function resolveGroupRoleMemoized(groupId, entityId, groupRoles, resolveRecord) {
|
|
133
|
+
const key = `${groupId}:${entityId}`;
|
|
134
|
+
const cached = groupRoles.get(key);
|
|
135
|
+
if (cached !== undefined)
|
|
136
|
+
return cached;
|
|
137
|
+
const group = await resolveRecord(groupId);
|
|
138
|
+
// Only a real `_group` Record carries a roster. Without the family check
|
|
139
|
+
// any Record's relationship associations would serve as one, and a group
|
|
140
|
+
// migrated out of the family would keep resolving after it had stopped
|
|
141
|
+
// being a group.
|
|
142
|
+
const role = group && baseIdOf(group.typeId) === SYSTEM_TYPES.GROUP
|
|
143
|
+
? groupRoleFromAssociations(group.associations, entityId)
|
|
144
|
+
: null;
|
|
145
|
+
groupRoles.set(key, role);
|
|
146
|
+
return role;
|
|
147
|
+
}
|
|
34
148
|
/**
|
|
35
149
|
* System type families grant() refuses to target: a grant on any of them
|
|
36
150
|
* would let the grantee mint their own grants, touch stack config, or
|
|
@@ -394,10 +508,34 @@ export class Stack {
|
|
|
394
508
|
typeCache = new Map();
|
|
395
509
|
/** Set by close(). See docs/spec/adapters.md § Lifecycle. */
|
|
396
510
|
closed = false;
|
|
511
|
+
/**
|
|
512
|
+
* Every change made through this Stack. `ScopedStack` filters this same
|
|
513
|
+
* stream rather than opening its own, so no scoped view can observe a
|
|
514
|
+
* change the stack did not emit. See docs/spec/events.md.
|
|
515
|
+
*/
|
|
516
|
+
changes = new ChangeEmitter();
|
|
397
517
|
constructor(adapter, idTimestampSkewMsValue) {
|
|
398
518
|
this.adapter = adapter;
|
|
399
519
|
this.idTimestampSkewMsValue = idTimestampSkewMsValue;
|
|
400
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Announce a mutation that has already been persisted. Called after the
|
|
523
|
+
* adapter write resolves and before the mutating method settles, so
|
|
524
|
+
* `await stack.update(...)` guarantees subscribers have been notified —
|
|
525
|
+
* and nothing about work they deferred. A handler cannot fail the write:
|
|
526
|
+
* there is nothing left to fail. See docs/spec/events.md § Handlers.
|
|
527
|
+
*/
|
|
528
|
+
emitChange(op, record, opts = {}) {
|
|
529
|
+
this.changes.emit(buildEmission(op, record, opts));
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* The requester behind a hard delete, which stamps nothing on a record
|
|
533
|
+
* that no longer exists. Owner-acting-alone is the only way to reach the
|
|
534
|
+
* verb, so there is never a principal to name beside the subject.
|
|
535
|
+
*/
|
|
536
|
+
static purgeActor(opts) {
|
|
537
|
+
return opts.updatedBy ? { entityId: opts.updatedBy } : undefined;
|
|
538
|
+
}
|
|
401
539
|
async getTypeCached(id) {
|
|
402
540
|
const cached = this.typeCache.get(id);
|
|
403
541
|
if (cached)
|
|
@@ -468,7 +606,7 @@ export class Stack {
|
|
|
468
606
|
if (opts.onBehalfOf && !entityId) {
|
|
469
607
|
throw new StackPermissionError('An anonymous principal cannot act on behalf of an entity');
|
|
470
608
|
}
|
|
471
|
-
return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter);
|
|
609
|
+
return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter, this.changes);
|
|
472
610
|
}
|
|
473
611
|
/**
|
|
474
612
|
* Scope to an authenticated session — what StackTokenStore.lookupToken()
|
|
@@ -675,7 +813,11 @@ export class Stack {
|
|
|
675
813
|
if (!type) {
|
|
676
814
|
throw new Error(`Unknown type: "${typeId}". Call defineType() first.`);
|
|
677
815
|
}
|
|
678
|
-
const errors = [
|
|
816
|
+
const errors = [
|
|
817
|
+
...validateReservedKeys(content),
|
|
818
|
+
...validateContent(content, type.schema),
|
|
819
|
+
...validatePermissions(opts.permissions),
|
|
820
|
+
];
|
|
679
821
|
if (errors.length > 0) {
|
|
680
822
|
throw new StackValidationError(errors);
|
|
681
823
|
}
|
|
@@ -701,10 +843,17 @@ export class Stack {
|
|
|
701
843
|
...(opts.entityId && { entityId: opts.entityId }),
|
|
702
844
|
...(opts.appId && { appId: opts.appId }),
|
|
703
845
|
...(opts.principalId && { principalId: opts.principalId }),
|
|
846
|
+
// A create's actor is its author, so these are derived rather than
|
|
847
|
+
// taken: stamping them here keeps "absent means an unscoped write"
|
|
848
|
+
// true of version 1 as it is of every later version.
|
|
849
|
+
...(opts.entityId && { updatedBy: opts.entityId }),
|
|
850
|
+
...(opts.principalId && { updatedVia: opts.principalId }),
|
|
704
851
|
...(opts.permissions?.length && { permissions: opts.permissions }),
|
|
705
852
|
...(associations?.length && { associations }),
|
|
706
853
|
};
|
|
707
|
-
|
|
854
|
+
const created = await this.adapter.createRecord(record);
|
|
855
|
+
this.emitChange('create', created);
|
|
856
|
+
return created;
|
|
708
857
|
}
|
|
709
858
|
/**
|
|
710
859
|
* Apply the registered migration chain in memory, for presentAt:
|
|
@@ -787,10 +936,14 @@ export class Stack {
|
|
|
787
936
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
788
937
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
789
938
|
}
|
|
790
|
-
|
|
939
|
+
const updated = await this.adapter.patchContent(id, content, {
|
|
791
940
|
expectedVersion: opts.ifVersion,
|
|
792
941
|
snapshot: this.buildVersionSnapshot(existing),
|
|
942
|
+
updatedBy: opts.updatedBy,
|
|
943
|
+
updatedVia: opts.updatedVia,
|
|
793
944
|
});
|
|
945
|
+
this.emitChange('update', updated);
|
|
946
|
+
return updated;
|
|
794
947
|
}
|
|
795
948
|
/**
|
|
796
949
|
* Add an association to a record. Snapshots the record's prior state and
|
|
@@ -807,10 +960,13 @@ export class Stack {
|
|
|
807
960
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
808
961
|
if ((existing.associations ?? []).some((a) => associationEqual(a, association)))
|
|
809
962
|
return;
|
|
810
|
-
await this.adapter.associate(id, association, {
|
|
963
|
+
const updated = await this.adapter.associate(id, association, {
|
|
811
964
|
expectedVersion: opts.ifVersion,
|
|
812
965
|
snapshot: this.buildVersionSnapshot(existing),
|
|
966
|
+
updatedBy: opts.updatedBy,
|
|
967
|
+
updatedVia: opts.updatedVia,
|
|
813
968
|
});
|
|
969
|
+
this.emitChange('associate', updated);
|
|
814
970
|
}
|
|
815
971
|
/**
|
|
816
972
|
* Remove an association from a record. Snapshots and bumps version, same
|
|
@@ -825,10 +981,13 @@ export class Stack {
|
|
|
825
981
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
826
982
|
if (!(existing.associations ?? []).some((a) => associationEqual(a, association)))
|
|
827
983
|
return;
|
|
828
|
-
await this.adapter.dissociate(id, association, {
|
|
984
|
+
const updated = await this.adapter.dissociate(id, association, {
|
|
829
985
|
expectedVersion: opts.ifVersion,
|
|
830
986
|
snapshot: this.buildVersionSnapshot(existing),
|
|
987
|
+
updatedBy: opts.updatedBy,
|
|
988
|
+
updatedVia: opts.updatedVia,
|
|
831
989
|
});
|
|
990
|
+
this.emitChange('dissociate', updated);
|
|
832
991
|
}
|
|
833
992
|
/**
|
|
834
993
|
* Replace all permissions on a record. Snapshots and bumps version, same
|
|
@@ -837,6 +996,10 @@ export class Stack {
|
|
|
837
996
|
*/
|
|
838
997
|
async setPermissions(id, permissions, opts = {}) {
|
|
839
998
|
this.assertOpen();
|
|
999
|
+
const errors = validatePermissions(permissions);
|
|
1000
|
+
if (errors.length > 0) {
|
|
1001
|
+
throw new StackValidationError(errors);
|
|
1002
|
+
}
|
|
840
1003
|
const existing = await this.adapter.getRecord(id);
|
|
841
1004
|
if (!existing) {
|
|
842
1005
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
@@ -844,10 +1007,13 @@ export class Stack {
|
|
|
844
1007
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
845
1008
|
if (permissionsEqual(existing.permissions ?? [], permissions))
|
|
846
1009
|
return;
|
|
847
|
-
await this.adapter.setPermissions(id, permissions, {
|
|
1010
|
+
const updated = await this.adapter.setPermissions(id, permissions, {
|
|
848
1011
|
expectedVersion: opts.ifVersion,
|
|
849
1012
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1013
|
+
updatedBy: opts.updatedBy,
|
|
1014
|
+
updatedVia: opts.updatedVia,
|
|
850
1015
|
});
|
|
1016
|
+
this.emitChange('permissions', updated);
|
|
851
1017
|
}
|
|
852
1018
|
/**
|
|
853
1019
|
* Soft-delete a record (default) or hard-delete it permanently, removing
|
|
@@ -862,7 +1028,17 @@ export class Stack {
|
|
|
862
1028
|
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
863
1029
|
}
|
|
864
1030
|
if (opts.hard) {
|
|
865
|
-
|
|
1031
|
+
// The adapter hands back what it destroyed, captured inside the same
|
|
1032
|
+
// write: a read here instead would race the delete, and afterwards
|
|
1033
|
+
// there is nothing left to read. Null means there was no record, so
|
|
1034
|
+
// nothing was purged and nothing is announced.
|
|
1035
|
+
const purged = await this.adapter.deleteRecord(id, {
|
|
1036
|
+
hard: true,
|
|
1037
|
+
expectedVersion: opts.ifVersion,
|
|
1038
|
+
});
|
|
1039
|
+
if (purged)
|
|
1040
|
+
this.emitChange('hard-delete', purged, { actor: Stack.purgeActor(opts) });
|
|
1041
|
+
return;
|
|
866
1042
|
}
|
|
867
1043
|
const existing = await this.adapter.getRecord(id);
|
|
868
1044
|
if (!existing) {
|
|
@@ -871,10 +1047,14 @@ export class Stack {
|
|
|
871
1047
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
872
1048
|
if (existing.deletedAt)
|
|
873
1049
|
return;
|
|
874
|
-
await this.adapter.deleteRecord(id, {
|
|
1050
|
+
const deleted = await this.adapter.deleteRecord(id, {
|
|
875
1051
|
expectedVersion: opts.ifVersion,
|
|
876
1052
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1053
|
+
updatedBy: opts.updatedBy,
|
|
1054
|
+
updatedVia: opts.updatedVia,
|
|
877
1055
|
});
|
|
1056
|
+
if (deleted)
|
|
1057
|
+
this.emitChange('delete', deleted);
|
|
878
1058
|
}
|
|
879
1059
|
/**
|
|
880
1060
|
* Reverse a soft delete. Idempotent — undeleting a record that isn't
|
|
@@ -891,10 +1071,14 @@ export class Stack {
|
|
|
891
1071
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
892
1072
|
if (!existing.deletedAt)
|
|
893
1073
|
return existing;
|
|
894
|
-
|
|
1074
|
+
const undeleted = await this.adapter.undeleteRecord(id, {
|
|
895
1075
|
expectedVersion: opts.ifVersion,
|
|
896
1076
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1077
|
+
updatedBy: opts.updatedBy,
|
|
1078
|
+
updatedVia: opts.updatedVia,
|
|
897
1079
|
});
|
|
1080
|
+
this.emitChange('undelete', undeleted);
|
|
1081
|
+
return undeleted;
|
|
898
1082
|
}
|
|
899
1083
|
/**
|
|
900
1084
|
* Query records. filter.baseId matches every version of a type family,
|
|
@@ -988,10 +1172,14 @@ export class Stack {
|
|
|
988
1172
|
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
989
1173
|
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
990
1174
|
}
|
|
991
|
-
|
|
1175
|
+
const restored = await this.adapter.restoreVersion(id, version, {
|
|
992
1176
|
expectedVersion: opts.ifVersion,
|
|
993
1177
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1178
|
+
updatedBy: opts.updatedBy,
|
|
1179
|
+
updatedVia: opts.updatedVia,
|
|
994
1180
|
});
|
|
1181
|
+
this.emitChange('restore', restored);
|
|
1182
|
+
return restored;
|
|
995
1183
|
}
|
|
996
1184
|
/**
|
|
997
1185
|
* Commit a per-record migration: replace `content` and `typeId` together
|
|
@@ -1020,7 +1208,7 @@ export class Stack {
|
|
|
1020
1208
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1021
1209
|
}
|
|
1022
1210
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1023
|
-
return this.commitMigrationChecked(existing, toTypeId, content, opts
|
|
1211
|
+
return this.commitMigrationChecked(existing, toTypeId, content, opts);
|
|
1024
1212
|
}
|
|
1025
1213
|
/**
|
|
1026
1214
|
* The checked migration write, shared by commitMigration() and
|
|
@@ -1037,7 +1225,7 @@ export class Stack {
|
|
|
1037
1225
|
* can cross type families; family-crossing is exactly what the checks
|
|
1038
1226
|
* below care about.
|
|
1039
1227
|
*/
|
|
1040
|
-
async commitMigrationChecked(existing, toTypeId, content,
|
|
1228
|
+
async commitMigrationChecked(existing, toTypeId, content, opts = {}) {
|
|
1041
1229
|
const id = existing.id;
|
|
1042
1230
|
const type = await this.getTypeCached(toTypeId);
|
|
1043
1231
|
if (!type) {
|
|
@@ -1074,10 +1262,14 @@ export class Stack {
|
|
|
1074
1262
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1075
1263
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1076
1264
|
}
|
|
1077
|
-
|
|
1078
|
-
expectedVersion: ifVersion,
|
|
1265
|
+
const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
|
|
1266
|
+
expectedVersion: opts.ifVersion,
|
|
1079
1267
|
snapshot: this.buildVersionSnapshot(existing),
|
|
1268
|
+
updatedBy: opts.updatedBy,
|
|
1269
|
+
updatedVia: opts.updatedVia,
|
|
1080
1270
|
});
|
|
1271
|
+
this.emitChange('migrate', migrated);
|
|
1272
|
+
return migrated;
|
|
1081
1273
|
}
|
|
1082
1274
|
/** Uniqueness for every unique binding field a newly created card claims. */
|
|
1083
1275
|
async checkBindingsOnCreate(typeId, content) {
|
|
@@ -1314,7 +1506,10 @@ export class Stack {
|
|
|
1314
1506
|
this.assertOpen();
|
|
1315
1507
|
assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
|
|
1316
1508
|
if (this.adapter.putAttachmentWithMetadata) {
|
|
1509
|
+
// The metadata record is written inside the adapter, so create()
|
|
1510
|
+
// never sees it and this is the only place it can be announced.
|
|
1317
1511
|
const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
|
|
1512
|
+
this.emitChange('create', record);
|
|
1318
1513
|
return record;
|
|
1319
1514
|
}
|
|
1320
1515
|
const fileId = await this.adapter.putAttachment(data);
|
|
@@ -1334,13 +1529,24 @@ export class Stack {
|
|
|
1334
1529
|
* Throws StackConflictError if any record in the stack still references the file.
|
|
1335
1530
|
* Throws StackNotFoundError if neither metadata records nor bytes exist.
|
|
1336
1531
|
*/
|
|
1337
|
-
async deleteAttachment(fileId) {
|
|
1532
|
+
async deleteAttachment(fileId, opts = {}) {
|
|
1338
1533
|
this.assertOpen();
|
|
1339
1534
|
const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1535
|
+
let deletedRecords;
|
|
1536
|
+
if (this.adapter.deleteUnreferencedAttachmentRecords) {
|
|
1537
|
+
// Purged inside the adapter's own transaction, so these never reach
|
|
1538
|
+
// delete() and are announced here instead — from the records it
|
|
1539
|
+
// hands back, which are the last copies that will ever exist.
|
|
1540
|
+
deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId);
|
|
1541
|
+
const at = new Date();
|
|
1542
|
+
for (const record of deletedRecords) {
|
|
1543
|
+
this.emitChange('hard-delete', record, { actor: Stack.purgeActor(opts), at });
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
else {
|
|
1547
|
+
deletedRecords = await this.deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts);
|
|
1548
|
+
}
|
|
1549
|
+
if (!deletedRecords.length) {
|
|
1344
1550
|
try {
|
|
1345
1551
|
await this.adapter.getAttachment(fileId);
|
|
1346
1552
|
}
|
|
@@ -1355,7 +1561,7 @@ export class Stack {
|
|
|
1355
1561
|
* deleteUnreferencedAttachmentRecords(): a concurrent associate() can
|
|
1356
1562
|
* race between the reference check below and the deletes it guards.
|
|
1357
1563
|
*/
|
|
1358
|
-
async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId) {
|
|
1564
|
+
async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId, opts = {}) {
|
|
1359
1565
|
// A soft-deleted record still counts as a reference — it must find its
|
|
1360
1566
|
// attachments intact on undelete. See docs/spec/attachments.md
|
|
1361
1567
|
// § Deleting attachments.
|
|
@@ -1379,9 +1585,9 @@ export class Stack {
|
|
|
1379
1585
|
? metaResults
|
|
1380
1586
|
: metaResults.filter((r) => r.content.fileId === fileId);
|
|
1381
1587
|
for (const record of metaRecords) {
|
|
1382
|
-
await this.delete(record.id, { hard: true });
|
|
1588
|
+
await this.delete(record.id, { hard: true, ...opts });
|
|
1383
1589
|
}
|
|
1384
|
-
return metaRecords
|
|
1590
|
+
return metaRecords;
|
|
1385
1591
|
}
|
|
1386
1592
|
/**
|
|
1387
1593
|
* Sweep for attachment bytes unreachable from any record — live or
|
|
@@ -1440,7 +1646,7 @@ export class Stack {
|
|
|
1440
1646
|
continue;
|
|
1441
1647
|
}
|
|
1442
1648
|
try {
|
|
1443
|
-
await this.deleteAttachment(fileId);
|
|
1649
|
+
await this.deleteAttachment(fileId, { updatedBy: opts.updatedBy });
|
|
1444
1650
|
}
|
|
1445
1651
|
catch (err) {
|
|
1446
1652
|
// Raced with a new reference, or another sweep/call already removed
|
|
@@ -1463,6 +1669,20 @@ export class Stack {
|
|
|
1463
1669
|
* ones that buffer, and for checkpointing a stack that stays open —
|
|
1464
1670
|
* close() covers the teardown case on its own.
|
|
1465
1671
|
*/
|
|
1672
|
+
/**
|
|
1673
|
+
* Observe every change made through this Stack. Unscoped, so no
|
|
1674
|
+
* permission filter applies — a caller holding a `Stack` already reaches
|
|
1675
|
+
* every record by other means; `ScopedStack.subscribe()` is the filtered
|
|
1676
|
+
* view. See docs/spec/events.md.
|
|
1677
|
+
*
|
|
1678
|
+
* Async so that a remote stack can resolve once its feed is live, which
|
|
1679
|
+
* makes subscribe-then-query the gap-free startup order everywhere. A
|
|
1680
|
+
* local stack is live immediately.
|
|
1681
|
+
*/
|
|
1682
|
+
async subscribe(handler, opts = {}) {
|
|
1683
|
+
this.assertOpen();
|
|
1684
|
+
return this.changes.subscribe(handler, opts);
|
|
1685
|
+
}
|
|
1466
1686
|
async flush() {
|
|
1467
1687
|
this.assertOpen();
|
|
1468
1688
|
await this.adapter.flush?.();
|
|
@@ -1480,6 +1700,7 @@ export class Stack {
|
|
|
1480
1700
|
// half-open and invite a second close() onto an already-closed adapter.
|
|
1481
1701
|
// Flushes through the adapter directly, past the now-tripped guard.
|
|
1482
1702
|
this.closed = true;
|
|
1703
|
+
this.changes.closeAll();
|
|
1483
1704
|
try {
|
|
1484
1705
|
await this.adapter.flush?.();
|
|
1485
1706
|
}
|
|
@@ -1500,8 +1721,9 @@ export class Stack {
|
|
|
1500
1721
|
// -------------------------------------------------------
|
|
1501
1722
|
/**
|
|
1502
1723
|
* Create _grant records authorizing entities to act on records of
|
|
1503
|
-
* specific types; null
|
|
1504
|
-
*
|
|
1724
|
+
* specific types; null target writes a default grant (any authenticated
|
|
1725
|
+
* entity); `{ groupId }` targets a `_group` Record's roster instead of a
|
|
1726
|
+
* single entity.
|
|
1505
1727
|
*
|
|
1506
1728
|
* Granting an **app** a `-own` action does not contain it the way the
|
|
1507
1729
|
* suffix suggests: when that app acts for someone, `-own` is read as the
|
|
@@ -1511,51 +1733,71 @@ export class Stack {
|
|
|
1511
1733
|
* needs, not the suffix that looks narrowest. See
|
|
1512
1734
|
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1513
1735
|
*
|
|
1514
|
-
* The grantee lives in content.granteeEntityId,
|
|
1736
|
+
* The grantee lives in content.granteeEntityId / content.granteeGroupId,
|
|
1515
1737
|
* not record.entityId. See docs/spec/access-control.md § Type-level
|
|
1516
1738
|
* grants.
|
|
1517
1739
|
*/
|
|
1518
|
-
async grant(
|
|
1740
|
+
async grant(target, grants) {
|
|
1519
1741
|
this.assertOpen();
|
|
1742
|
+
validateGrantTarget(target);
|
|
1520
1743
|
this.checkGrantsValid(grants);
|
|
1521
1744
|
const records = [];
|
|
1522
1745
|
for (const g of grants) {
|
|
1523
1746
|
records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1524
1747
|
typeId: g.typeId,
|
|
1525
1748
|
actions: g.actions,
|
|
1526
|
-
...(
|
|
1749
|
+
...(typeof target === 'string' && { granteeEntityId: target }),
|
|
1750
|
+
...(target !== null && typeof target === 'object' && { granteeGroupId: target.groupId }),
|
|
1527
1751
|
}));
|
|
1528
1752
|
}
|
|
1529
1753
|
return records;
|
|
1530
1754
|
}
|
|
1531
1755
|
/**
|
|
1532
|
-
* List _grant records. Omit `
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1756
|
+
* List _grant records. Omit `target` for all grants; pass null for only
|
|
1757
|
+
* default grants; pass `{ groupId }` for grants naming that exact group;
|
|
1758
|
+
* pass a specific entityId for the grants that currently apply to that
|
|
1759
|
+
* entity (ones naming them, ones naming a group they belong to, plus
|
|
1760
|
+
* every default grant) — the same resolution hasGrant() uses.
|
|
1536
1761
|
*/
|
|
1537
|
-
async listGrants(
|
|
1762
|
+
async listGrants(target) {
|
|
1538
1763
|
this.assertOpen();
|
|
1764
|
+
if (target !== undefined)
|
|
1765
|
+
validateGrantTarget(target);
|
|
1539
1766
|
const all = await queryAllPages((q) => this.query(q), {
|
|
1540
1767
|
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
1541
1768
|
});
|
|
1542
|
-
if (
|
|
1769
|
+
if (target === undefined)
|
|
1543
1770
|
return all;
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1771
|
+
if (target === null || typeof target === 'object') {
|
|
1772
|
+
return all.filter((r) => matchesGrantTarget(r.content, target));
|
|
1773
|
+
}
|
|
1774
|
+
// target is an EntityId: resolve group rosters, since a grant naming a
|
|
1775
|
+
// group the entity belongs to also currently applies to them. Shares
|
|
1776
|
+
// grantCoversGrantee() with the access checks, so a listing can't
|
|
1777
|
+
// disagree with them about who a grant covers.
|
|
1778
|
+
const groupRoles = new Map();
|
|
1779
|
+
const result = [];
|
|
1780
|
+
for (const r of all) {
|
|
1781
|
+
const covers = await grantCoversGrantee(r.content, target, {
|
|
1782
|
+
allowDefault: true,
|
|
1783
|
+
allowGroup: true,
|
|
1784
|
+
groupRoles,
|
|
1785
|
+
resolveRecord: (id) => this.get(id),
|
|
1786
|
+
});
|
|
1787
|
+
if (covers)
|
|
1788
|
+
result.push(r);
|
|
1789
|
+
}
|
|
1790
|
+
return result;
|
|
1550
1791
|
}
|
|
1551
1792
|
/**
|
|
1552
|
-
* The inverse of grant(): soft-deletes _grant records matching `
|
|
1793
|
+
* The inverse of grant(): soft-deletes _grant records matching `target`
|
|
1553
1794
|
* (null for default grants) and each `{ typeId, actions }` pair, at the
|
|
1554
1795
|
* same granularity grant() writes. A soft delete like any other — the
|
|
1555
1796
|
* owner can undelete a revocation.
|
|
1556
1797
|
*/
|
|
1557
|
-
async revoke(
|
|
1798
|
+
async revoke(target, grants) {
|
|
1558
1799
|
this.assertOpen();
|
|
1800
|
+
validateGrantTarget(target);
|
|
1559
1801
|
const all = await queryAllPages((q) => this.query(q), {
|
|
1560
1802
|
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
1561
1803
|
});
|
|
@@ -1566,7 +1808,7 @@ export class Stack {
|
|
|
1566
1808
|
const c = r.content;
|
|
1567
1809
|
if (baseIdOf(c.typeId) !== familyId)
|
|
1568
1810
|
return false;
|
|
1569
|
-
if ((c
|
|
1811
|
+
if (!matchesGrantTarget(c, target))
|
|
1570
1812
|
return false;
|
|
1571
1813
|
return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
|
|
1572
1814
|
});
|
|
@@ -1610,6 +1852,17 @@ export class Stack {
|
|
|
1610
1852
|
message: `Cannot grant on "${baseIdOf(g.typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
|
|
1611
1853
|
});
|
|
1612
1854
|
}
|
|
1855
|
+
g.actions.forEach((action, j) => {
|
|
1856
|
+
if (grantConveys(g.actions, action))
|
|
1857
|
+
return;
|
|
1858
|
+
const companions = READ_COMPANIONS.get(action);
|
|
1859
|
+
if (!companions)
|
|
1860
|
+
return;
|
|
1861
|
+
errors.push({
|
|
1862
|
+
path: `grants[${i}].actions[${j}]`,
|
|
1863
|
+
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`,
|
|
1864
|
+
});
|
|
1865
|
+
});
|
|
1613
1866
|
});
|
|
1614
1867
|
if (errors.length > 0) {
|
|
1615
1868
|
throw new StackValidationError(errors);
|
|
@@ -1641,6 +1894,7 @@ export class Stack {
|
|
|
1641
1894
|
typeId: { kind: 'string', required: true },
|
|
1642
1895
|
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
1643
1896
|
granteeEntityId: { kind: 'string' },
|
|
1897
|
+
granteeGroupId: { kind: 'string' },
|
|
1644
1898
|
});
|
|
1645
1899
|
await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
|
|
1646
1900
|
fileId: { kind: 'string', required: true },
|
|
@@ -1674,6 +1928,8 @@ export class Stack {
|
|
|
1674
1928
|
content: record.content,
|
|
1675
1929
|
updatedAt: record.updatedAt,
|
|
1676
1930
|
...(record.entityId && { entityId: record.entityId }),
|
|
1931
|
+
...(record.updatedBy && { updatedBy: record.updatedBy }),
|
|
1932
|
+
...(record.updatedVia && { updatedVia: record.updatedVia }),
|
|
1677
1933
|
associations: record.associations ?? [],
|
|
1678
1934
|
...(record.permissions && { permissions: record.permissions }),
|
|
1679
1935
|
};
|
|
@@ -1761,23 +2017,128 @@ function stripVersionPermissions(version) {
|
|
|
1761
2017
|
* grants). Under delegation both halves apply, and a mistake on the
|
|
1762
2018
|
* authority side is an escalation rather than a preference.
|
|
1763
2019
|
*/
|
|
2020
|
+
/**
|
|
2021
|
+
* The authority lookups canRead needs, held for the life of one
|
|
2022
|
+
* subscription. A subscription is long-lived where a query is not, so the
|
|
2023
|
+
* cache is only safe because every write that can change canRead's answer
|
|
2024
|
+
* arrives as an event that drops it — see ScopedSubscription.
|
|
2025
|
+
*/
|
|
2026
|
+
class FeedAuthorityCache {
|
|
2027
|
+
grantRecords = null;
|
|
2028
|
+
/**
|
|
2029
|
+
* Bumped by every invalidation, so a load that was already in flight can
|
|
2030
|
+
* tell that its result is stale before seating it.
|
|
2031
|
+
*/
|
|
2032
|
+
generation = 0;
|
|
2033
|
+
/** Roster roles, memoized per group, as ScopedStack.query() does per query. */
|
|
2034
|
+
roles = new Map();
|
|
2035
|
+
/** Every `_grant` record, refilled through `load` after an invalidation. */
|
|
2036
|
+
async grants(load) {
|
|
2037
|
+
if (this.grantRecords !== null)
|
|
2038
|
+
return this.grantRecords;
|
|
2039
|
+
const generation = this.generation;
|
|
2040
|
+
const loaded = await load();
|
|
2041
|
+
// An invalidation during the load already dropped the set these
|
|
2042
|
+
// replace, so seating them would outlive the write that expired them
|
|
2043
|
+
// and no later event would drop them again. The event being decided
|
|
2044
|
+
// precedes that write, so it is still decided on what was loaded.
|
|
2045
|
+
if (this.generation === generation)
|
|
2046
|
+
this.grantRecords = loaded;
|
|
2047
|
+
return loaded;
|
|
2048
|
+
}
|
|
2049
|
+
invalidateFor(typeFamily) {
|
|
2050
|
+
if (typeFamily === SYSTEM_TYPES.GRANT) {
|
|
2051
|
+
this.grantRecords = null;
|
|
2052
|
+
this.generation++;
|
|
2053
|
+
}
|
|
2054
|
+
if (typeFamily === SYSTEM_TYPES.GROUP)
|
|
2055
|
+
this.roles = new Map();
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* A ScopedStack's delivery: canRead per event, with the grant and roster
|
|
2060
|
+
* lookups it needs cached for the life of the subscription and dropped the
|
|
2061
|
+
* moment anything that feeds them changes.
|
|
2062
|
+
*
|
|
2063
|
+
* Two properties do the work, and neither is optional:
|
|
2064
|
+
*
|
|
2065
|
+
* **Deliveries are serialized.** The permission decision is asynchronous,
|
|
2066
|
+
* so without a queue two changes to one record could resolve out of order
|
|
2067
|
+
* and be delivered newest-first — breaking the one ordering guarantee the
|
|
2068
|
+
* feed makes. Every emission is appended to a chain instead.
|
|
2069
|
+
*
|
|
2070
|
+
* **The filter fails closed.** A permission check that throws drops the
|
|
2071
|
+
* event and reports the error; it never delivers on the assumption that a
|
|
2072
|
+
* failed check would have passed.
|
|
2073
|
+
*
|
|
2074
|
+
* See docs/spec/events.md § Permission scoping.
|
|
2075
|
+
*/
|
|
2076
|
+
class ScopedSubscription extends Subscription {
|
|
2077
|
+
canRead;
|
|
2078
|
+
cache = new FeedAuthorityCache();
|
|
2079
|
+
/** Tail of the delivery chain — see the class comment. */
|
|
2080
|
+
queue = Promise.resolve();
|
|
2081
|
+
constructor(canRead, handler, opts) {
|
|
2082
|
+
super(handler, opts);
|
|
2083
|
+
this.canRead = canRead;
|
|
2084
|
+
}
|
|
2085
|
+
accept(emission) {
|
|
2086
|
+
// Invalidation reads every emission, including the ones this
|
|
2087
|
+
// subscriber may not see: a revocation the subscriber cannot read is
|
|
2088
|
+
// exactly the one that must still expire its cache.
|
|
2089
|
+
this.invalidateFor(emission);
|
|
2090
|
+
if (!matchesFilter(emission, this.opts.filter))
|
|
2091
|
+
return;
|
|
2092
|
+
this.queue = this.queue.then(() => this.filterAndDeliver(emission));
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* A cached grant set outlives the write that revokes it unless something
|
|
2096
|
+
* drops it. Both writes that can change canRead's answer — a `_grant`
|
|
2097
|
+
* record, or a `_group` roster — arrive here as ordinary events, which
|
|
2098
|
+
* is what makes the cache safe to hold at all. A future authority change
|
|
2099
|
+
* that did not emit would silently strand it.
|
|
2100
|
+
*/
|
|
2101
|
+
invalidateFor(emission) {
|
|
2102
|
+
this.cache.invalidateFor(baseIdOf(emission.change.typeId));
|
|
2103
|
+
}
|
|
2104
|
+
async filterAndDeliver(emission) {
|
|
2105
|
+
if (this.isClosed)
|
|
2106
|
+
return;
|
|
2107
|
+
try {
|
|
2108
|
+
if (!(await this.canRead(emission.record, this.cache)))
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
catch (err) {
|
|
2112
|
+
// Fail closed: an undecided permission question is not a yes.
|
|
2113
|
+
this.reportError(err);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
this.deliver(emission);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
1764
2119
|
export class ScopedStack {
|
|
1765
2120
|
stack;
|
|
1766
2121
|
principalEntityId;
|
|
1767
2122
|
subjectEntityId;
|
|
1768
2123
|
idTimestampSkewMs;
|
|
1769
2124
|
adapter;
|
|
2125
|
+
changes;
|
|
1770
2126
|
constructor(stack, principalEntityId, subjectEntityId, idTimestampSkewMs,
|
|
1771
2127
|
// Bytes-storage primitive for putAttachment(). Held directly because
|
|
1772
2128
|
// ScopedStack always composes bytes + its own create() — the record
|
|
1773
2129
|
// must carry the subject's entityId and the principal behind it,
|
|
1774
2130
|
// neither of which the adapter-level atomic capability takes.
|
|
1775
|
-
adapter
|
|
2131
|
+
adapter,
|
|
2132
|
+
// The stack's own stream, filtered by subscribe(). Held here rather
|
|
2133
|
+
// than reached through a method on Stack so the unfiltered stream
|
|
2134
|
+
// stays inside this module.
|
|
2135
|
+
changes) {
|
|
1776
2136
|
this.stack = stack;
|
|
1777
2137
|
this.principalEntityId = principalEntityId;
|
|
1778
2138
|
this.subjectEntityId = subjectEntityId;
|
|
1779
2139
|
this.idTimestampSkewMs = idTimestampSkewMs;
|
|
1780
2140
|
this.adapter = adapter;
|
|
2141
|
+
this.changes = changes;
|
|
1781
2142
|
}
|
|
1782
2143
|
get features() {
|
|
1783
2144
|
return this.stack.features;
|
|
@@ -1787,6 +2148,21 @@ export class ScopedStack {
|
|
|
1787
2148
|
get delegated() {
|
|
1788
2149
|
return this.subjectEntityId !== this.principalEntityId;
|
|
1789
2150
|
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Who this request is, for stamping onto whatever it mutates. Attribution
|
|
2153
|
+
* follows record-level authorship: the subject is the actor, and the
|
|
2154
|
+
* principal is named beside it only when the two differ.
|
|
2155
|
+
* See docs/spec/data-model.md § Authorship and attribution.
|
|
2156
|
+
*/
|
|
2157
|
+
get actor() {
|
|
2158
|
+
// Both keys are always present, so spreading this last overrides
|
|
2159
|
+
// anything a caller passed: a scoped requester names itself by making
|
|
2160
|
+
// the request, never by describing itself in the options.
|
|
2161
|
+
return {
|
|
2162
|
+
updatedBy: this.subjectEntityId ?? undefined,
|
|
2163
|
+
updatedVia: this.delegated ? (this.principalEntityId ?? undefined) : undefined,
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
1790
2166
|
/**
|
|
1791
2167
|
* Whether unconditional owner authority applies — the owner acting as
|
|
1792
2168
|
* itself. The verbs that rest on it are irreversible or disclose the
|
|
@@ -1817,9 +2193,13 @@ export class ScopedStack {
|
|
|
1817
2193
|
* See docs/spec/access-control.md § Type-level grants.
|
|
1818
2194
|
*/
|
|
1819
2195
|
async hasGrant(typeId, actions, opts) {
|
|
1820
|
-
const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true } = opts;
|
|
2196
|
+
const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
|
|
1821
2197
|
if (!grantee)
|
|
1822
2198
|
return false;
|
|
2199
|
+
// Absent when the caller has no operation-scoped map to share — one
|
|
2200
|
+
// call's worth of memoization, which is still every grant record in
|
|
2201
|
+
// this loop naming the same group.
|
|
2202
|
+
const groupRoles = opts.groupRoles ?? new Map();
|
|
1823
2203
|
const familyId = baseIdOf(typeId);
|
|
1824
2204
|
// grant() refuses to write these, but a _grant record is an ordinary
|
|
1825
2205
|
// Record: an unscoped Stack, an import, or a server mapping a request
|
|
@@ -1839,22 +2219,29 @@ export class ScopedStack {
|
|
|
1839
2219
|
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
1840
2220
|
});
|
|
1841
2221
|
}
|
|
1842
|
-
|
|
2222
|
+
for (const r of grantRecords) {
|
|
1843
2223
|
const c = r.content;
|
|
1844
2224
|
if (baseIdOf(c.typeId) !== familyId)
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
2225
|
+
continue;
|
|
2226
|
+
const covers = await grantCoversGrantee(c, grantee, {
|
|
2227
|
+
allowDefault,
|
|
2228
|
+
allowGroup,
|
|
2229
|
+
groupRoles,
|
|
2230
|
+
resolveRecord: this.resolveRecord,
|
|
2231
|
+
});
|
|
2232
|
+
if (!covers)
|
|
2233
|
+
continue;
|
|
2234
|
+
const matches = actions.some((action) => {
|
|
2235
|
+
if (!grantConveys(c.actions, action))
|
|
1852
2236
|
return false;
|
|
1853
2237
|
if (matchOwn && action.endsWith('-own'))
|
|
1854
2238
|
return record?.entityId === grantee;
|
|
1855
2239
|
return true;
|
|
1856
2240
|
});
|
|
1857
|
-
|
|
2241
|
+
if (matches)
|
|
2242
|
+
return true;
|
|
2243
|
+
}
|
|
2244
|
+
return false;
|
|
1858
2245
|
}
|
|
1859
2246
|
/**
|
|
1860
2247
|
* The principal half of a delegated request's authority: does the app
|
|
@@ -1868,6 +2255,11 @@ export class ScopedStack {
|
|
|
1868
2255
|
* people who turn up, not software the owner installed — an app reaches
|
|
1869
2256
|
* only the types named to it, which is the whole of what containment
|
|
1870
2257
|
* promises.
|
|
2258
|
+
*
|
|
2259
|
+
* Group-targeted grants don't count here either, one step removed: a
|
|
2260
|
+
* roster is editable by any of the group's admins, so authority reaching
|
|
2261
|
+
* a principal through one would let someone other than the owner name an
|
|
2262
|
+
* app to a type. See docs/spec/access-control.md § Type-level grants.
|
|
1871
2263
|
*/
|
|
1872
2264
|
principalAllows(typeId, actions, prefetchedGrants) {
|
|
1873
2265
|
if (!this.delegated)
|
|
@@ -1879,6 +2271,7 @@ export class ScopedStack {
|
|
|
1879
2271
|
prefetchedGrants,
|
|
1880
2272
|
matchOwn: false,
|
|
1881
2273
|
allowDefault: false,
|
|
2274
|
+
allowGroup: false,
|
|
1882
2275
|
});
|
|
1883
2276
|
}
|
|
1884
2277
|
/**
|
|
@@ -1895,13 +2288,15 @@ export class ScopedStack {
|
|
|
1895
2288
|
grantee: this.subjectEntityId,
|
|
1896
2289
|
record: opts.record,
|
|
1897
2290
|
prefetchedGrants: opts.prefetchedGrants,
|
|
2291
|
+
groupRoles: opts.groupRoles,
|
|
1898
2292
|
});
|
|
1899
2293
|
}
|
|
1900
|
-
async canRead(record, prefetchedGrants) {
|
|
2294
|
+
async canRead(record, prefetchedGrants, groupRoles) {
|
|
1901
2295
|
const reachable = (await this.checkRead(record)) ||
|
|
1902
2296
|
(await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
|
|
1903
2297
|
record,
|
|
1904
2298
|
prefetchedGrants,
|
|
2299
|
+
groupRoles,
|
|
1905
2300
|
}));
|
|
1906
2301
|
if (!reachable)
|
|
1907
2302
|
return false;
|
|
@@ -2004,7 +2399,8 @@ export class ScopedStack {
|
|
|
2004
2399
|
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
2005
2400
|
})
|
|
2006
2401
|
: undefined;
|
|
2007
|
-
const
|
|
2402
|
+
const groupRoles = new Map();
|
|
2403
|
+
const match = await findFirstMatch((q) => this.stack.query(q), { filter: { attachmentFileId: fileId } }, (record) => this.canRead(record, prefetchedGrants, groupRoles));
|
|
2008
2404
|
return match !== undefined;
|
|
2009
2405
|
}
|
|
2010
2406
|
/**
|
|
@@ -2182,12 +2578,16 @@ export class ScopedStack {
|
|
|
2182
2578
|
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
2183
2579
|
})
|
|
2184
2580
|
: undefined;
|
|
2581
|
+
// Scoped to this query, like prefetchedGrants beside it: every
|
|
2582
|
+
// candidate Record shares one roster resolution per group, and nothing
|
|
2583
|
+
// is carried into the next operation.
|
|
2584
|
+
const groupRoles = new Map();
|
|
2185
2585
|
let page = { records: [], cursor: query.cursor ?? null, total: null };
|
|
2186
2586
|
do {
|
|
2187
2587
|
page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
|
|
2188
2588
|
totalFetched += page.records.length;
|
|
2189
2589
|
for (const record of page.records) {
|
|
2190
|
-
if (await this.canRead(record, prefetchedGrants))
|
|
2590
|
+
if (await this.canRead(record, prefetchedGrants, groupRoles))
|
|
2191
2591
|
records.push(record);
|
|
2192
2592
|
}
|
|
2193
2593
|
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
@@ -2206,7 +2606,7 @@ export class ScopedStack {
|
|
|
2206
2606
|
this.requireOwnerForOwnerDid(record.typeId, content.did);
|
|
2207
2607
|
}
|
|
2208
2608
|
await this.requireFileRefAccess(record.typeId, content);
|
|
2209
|
-
return this.stack.update(id, content, opts);
|
|
2609
|
+
return this.stack.update(id, content, { ...opts, ...this.actor });
|
|
2210
2610
|
}
|
|
2211
2611
|
/**
|
|
2212
2612
|
* Naming the software behind a key is the trust decision the `_app`
|
|
@@ -2298,11 +2698,11 @@ export class ScopedStack {
|
|
|
2298
2698
|
async associate(id, association, opts = {}) {
|
|
2299
2699
|
const record = await this.requireUpdatable(id);
|
|
2300
2700
|
await this.requireAssociationAccess(record.typeId, association);
|
|
2301
|
-
return this.stack.associate(id, association, opts);
|
|
2701
|
+
return this.stack.associate(id, association, { ...opts, ...this.actor });
|
|
2302
2702
|
}
|
|
2303
2703
|
async dissociate(id, association, opts = {}) {
|
|
2304
2704
|
await this.requireUpdatable(id);
|
|
2305
|
-
return this.stack.dissociate(id, association, opts);
|
|
2705
|
+
return this.stack.dissociate(id, association, { ...opts, ...this.actor });
|
|
2306
2706
|
}
|
|
2307
2707
|
async setPermissions(id, permissions, opts = {}) {
|
|
2308
2708
|
const record = await this.stack.get(id);
|
|
@@ -2328,7 +2728,7 @@ export class ScopedStack {
|
|
|
2328
2728
|
throw new StackPermissionError();
|
|
2329
2729
|
}
|
|
2330
2730
|
}
|
|
2331
|
-
return this.stack.setPermissions(id, permissions, opts);
|
|
2731
|
+
return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor });
|
|
2332
2732
|
}
|
|
2333
2733
|
/**
|
|
2334
2734
|
* Hard delete is owner-only: it is irreversible and destroys version
|
|
@@ -2341,7 +2741,7 @@ export class ScopedStack {
|
|
|
2341
2741
|
if (opts.hard && !this.ownerActingAlone) {
|
|
2342
2742
|
throw new StackPermissionError('Hard delete is owner-only');
|
|
2343
2743
|
}
|
|
2344
|
-
return this.stack.delete(id, opts);
|
|
2744
|
+
return this.stack.delete(id, { ...opts, ...this.actor });
|
|
2345
2745
|
}
|
|
2346
2746
|
/**
|
|
2347
2747
|
* Reverse a soft delete. Gated the same as delete() — undelete is the
|
|
@@ -2350,7 +2750,7 @@ export class ScopedStack {
|
|
|
2350
2750
|
*/
|
|
2351
2751
|
async undelete(id, opts = {}) {
|
|
2352
2752
|
await this.requireDeletable(id);
|
|
2353
|
-
return this.stack.undelete(id, opts);
|
|
2753
|
+
return this.stack.undelete(id, { ...opts, ...this.actor });
|
|
2354
2754
|
}
|
|
2355
2755
|
/**
|
|
2356
2756
|
* History is the mutation/recovery surface, not a read surface — gated
|
|
@@ -2398,7 +2798,7 @@ export class ScopedStack {
|
|
|
2398
2798
|
}
|
|
2399
2799
|
}
|
|
2400
2800
|
}
|
|
2401
|
-
return this.stack.restoreVersion(id, version, opts);
|
|
2801
|
+
return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
|
|
2402
2802
|
}
|
|
2403
2803
|
/**
|
|
2404
2804
|
* Commit a per-record migration — **the owner acting alone, only**.
|
|
@@ -2431,7 +2831,7 @@ export class ScopedStack {
|
|
|
2431
2831
|
if (!this.ownerActingAlone) {
|
|
2432
2832
|
throw new StackPermissionError('Only the stack owner may commit a migration');
|
|
2433
2833
|
}
|
|
2434
|
-
return this.stack.commitMigration(id, toTypeId, content, opts);
|
|
2834
|
+
return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
|
|
2435
2835
|
}
|
|
2436
2836
|
/**
|
|
2437
2837
|
* Store bytes and create an _attachment@1 metadata record (create grant
|
|
@@ -2482,7 +2882,7 @@ export class ScopedStack {
|
|
|
2482
2882
|
if (!this.ownerActingAlone) {
|
|
2483
2883
|
throw new StackPermissionError('Only the stack owner can delete attachments');
|
|
2484
2884
|
}
|
|
2485
|
-
return this.stack.deleteAttachment(fileId);
|
|
2885
|
+
return this.stack.deleteAttachment(fileId, this.actor);
|
|
2486
2886
|
}
|
|
2487
2887
|
/**
|
|
2488
2888
|
* Sweep for unreferenced attachment bytes and delete them. Only the stack
|
|
@@ -2492,7 +2892,35 @@ export class ScopedStack {
|
|
|
2492
2892
|
if (!this.ownerActingAlone) {
|
|
2493
2893
|
throw new StackPermissionError('Only the stack owner can collect attachment garbage');
|
|
2494
2894
|
}
|
|
2495
|
-
return this.stack.collectAttachmentGarbage(opts);
|
|
2895
|
+
return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Observe the changes this request may read. The predicate is canRead
|
|
2899
|
+
* applied per event — the same one get() and query() answer with, so a
|
|
2900
|
+
* feed can't disagree with them about what this session sees.
|
|
2901
|
+
*
|
|
2902
|
+
* A record the subscriber cannot read produces no event at all, rather
|
|
2903
|
+
* than an empty or redacted one: the existence of a change is itself a
|
|
2904
|
+
* disclosure, the same reasoning that keeps query()'s `total` null.
|
|
2905
|
+
* See docs/spec/events.md § Permission scoping.
|
|
2906
|
+
*/
|
|
2907
|
+
async subscribe(handler, opts = {}) {
|
|
2908
|
+
this.stack.assertOpen();
|
|
2909
|
+
return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* canRead for one event, through the subscription's own cache. Grants
|
|
2913
|
+
* are prefetched once and refilled after an invalidation rather than
|
|
2914
|
+
* re-queried per event, which would be a `_grant` scan for every change
|
|
2915
|
+
* the stack makes.
|
|
2916
|
+
*/
|
|
2917
|
+
async canReadCached(record, cache) {
|
|
2918
|
+
const grants = this.principalEntityId
|
|
2919
|
+
? await cache.grants(() => queryAllPages((q) => this.stack.query(q), {
|
|
2920
|
+
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
2921
|
+
}))
|
|
2922
|
+
: undefined;
|
|
2923
|
+
return this.canRead(record, grants, cache.roles);
|
|
2496
2924
|
}
|
|
2497
2925
|
}
|
|
2498
2926
|
//# sourceMappingURL=stack.js.map
|