@haverstack/core 0.42.0 → 0.44.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 +6 -9
- package/dist/access.d.ts +36 -34
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +51 -100
- package/dist/access.js.map +1 -1
- package/dist/adapter/combine.d.ts.map +1 -1
- package/dist/adapter/combine.js +14 -7
- package/dist/adapter/combine.js.map +1 -1
- package/dist/adapter/copy.d.ts +27 -0
- package/dist/adapter/copy.d.ts.map +1 -0
- package/dist/adapter/copy.js +105 -0
- package/dist/adapter/copy.js.map +1 -0
- package/dist/adapter/index.d.ts +9 -4
- package/dist/adapter/index.d.ts.map +1 -1
- package/dist/adapter/index.js +6 -3
- package/dist/adapter/index.js.map +1 -1
- package/dist/adapter/reach.d.ts +14 -0
- package/dist/adapter/reach.d.ts.map +1 -0
- package/dist/adapter/reach.js +31 -0
- package/dist/adapter/reach.js.map +1 -0
- package/dist/adapter/seed.d.ts +13 -0
- package/dist/adapter/seed.d.ts.map +1 -0
- package/dist/adapter/seed.js +26 -0
- package/dist/adapter/seed.js.map +1 -0
- package/dist/associations/identity.d.ts +18 -42
- package/dist/associations/identity.d.ts.map +1 -1
- package/dist/associations/identity.js +60 -84
- package/dist/associations/identity.js.map +1 -1
- package/dist/associations/validation.d.ts +1 -8
- package/dist/associations/validation.d.ts.map +1 -1
- package/dist/associations/validation.js +3 -30
- package/dist/associations/validation.js.map +1 -1
- package/dist/collection.d.ts +7 -11
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +24 -34
- package/dist/collection.js.map +1 -1
- package/dist/content-match.d.ts +2 -2
- package/dist/content-match.js +2 -2
- package/dist/errors.d.ts +41 -12
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +56 -15
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +18 -6
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +48 -37
- package/dist/grants.js.map +1 -1
- package/dist/hash.d.ts +8 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +14 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +29 -5
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +69 -44
- package/dist/install.js.map +1 -1
- package/dist/lazy.d.ts +13 -0
- package/dist/lazy.d.ts.map +1 -0
- package/dist/lazy.js +27 -0
- package/dist/lazy.js.map +1 -0
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +1 -1
- package/dist/query-validation.d.ts +41 -28
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +85 -71
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +12 -36
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +9 -98
- package/dist/record-changes.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -5
- package/dist/schema.js.map +1 -1
- package/dist/scoped-stack/authority-snapshot.d.ts +37 -0
- package/dist/scoped-stack/authority-snapshot.d.ts.map +1 -0
- package/dist/scoped-stack/authority-snapshot.js +49 -0
- package/dist/scoped-stack/authority-snapshot.js.map +1 -0
- package/dist/scoped-stack/authority.d.ts +69 -51
- package/dist/scoped-stack/authority.d.ts.map +1 -1
- package/dist/scoped-stack/authority.js +189 -153
- package/dist/scoped-stack/authority.js.map +1 -1
- package/dist/scoped-stack/family-policy.d.ts +59 -0
- package/dist/scoped-stack/family-policy.d.ts.map +1 -0
- package/dist/scoped-stack/family-policy.js +68 -0
- package/dist/scoped-stack/family-policy.js.map +1 -0
- package/dist/scoped-stack/scoped-stack.d.ts +73 -86
- package/dist/scoped-stack/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack/scoped-stack.js +279 -337
- package/dist/scoped-stack/scoped-stack.js.map +1 -1
- package/dist/scoped-stack/subscription.d.ts +14 -33
- package/dist/scoped-stack/subscription.d.ts.map +1 -1
- package/dist/scoped-stack/subscription.js +16 -57
- package/dist/scoped-stack/subscription.js.map +1 -1
- package/dist/stack/arguments.d.ts +27 -0
- package/dist/stack/arguments.d.ts.map +1 -0
- package/dist/stack/arguments.js +53 -0
- package/dist/stack/arguments.js.map +1 -0
- package/dist/stack/attachments.d.ts +1 -1
- package/dist/stack/attachments.js +1 -1
- package/dist/stack/changes.d.ts +4 -10
- package/dist/stack/changes.d.ts.map +1 -1
- package/dist/stack/changes.js +8 -8
- package/dist/stack/changes.js.map +1 -1
- package/dist/stack/client.d.ts +34 -58
- package/dist/stack/client.d.ts.map +1 -1
- package/dist/stack/field-rules.d.ts +27 -0
- package/dist/stack/field-rules.d.ts.map +1 -0
- package/dist/stack/field-rules.js +50 -0
- package/dist/stack/field-rules.js.map +1 -0
- package/dist/stack/install-app.d.ts +2 -2
- package/dist/stack/install-app.d.ts.map +1 -1
- package/dist/stack/install-app.js +16 -14
- package/dist/stack/install-app.js.map +1 -1
- package/dist/stack/integrity.d.ts +9 -53
- package/dist/stack/integrity.d.ts.map +1 -1
- package/dist/stack/integrity.js +47 -107
- package/dist/stack/integrity.js.map +1 -1
- package/dist/stack/limits.d.ts +1 -1
- package/dist/stack/limits.js +1 -1
- package/dist/stack/reads.d.ts +9 -5
- package/dist/stack/reads.d.ts.map +1 -1
- package/dist/stack/reads.js +24 -4
- package/dist/stack/reads.js.map +1 -1
- package/dist/stack/stack.d.ts +84 -79
- package/dist/stack/stack.d.ts.map +1 -1
- package/dist/stack/stack.js +236 -306
- package/dist/stack/stack.js.map +1 -1
- package/dist/stack/system-types.d.ts.map +1 -1
- package/dist/stack/system-types.js +1 -3
- package/dist/stack/system-types.js.map +1 -1
- package/dist/stack/type-grants.d.ts.map +1 -1
- package/dist/stack/type-grants.js +9 -6
- package/dist/stack/type-grants.js.map +1 -1
- package/dist/stack/write-expectation.d.ts +13 -12
- package/dist/stack/write-expectation.d.ts.map +1 -1
- package/dist/stack/write-expectation.js.map +1 -1
- package/dist/stack/write-gate.d.ts +23 -0
- package/dist/stack/write-gate.d.ts.map +1 -0
- package/dist/stack/write-gate.js +17 -0
- package/dist/stack/write-gate.js.map +1 -0
- package/dist/testing.d.ts +37 -24
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +176 -153
- package/dist/testing.js.map +1 -1
- package/dist/types/adapter.d.ts +115 -43
- package/dist/types/adapter.d.ts.map +1 -1
- package/dist/types/associations.d.ts +1 -17
- package/dist/types/associations.d.ts.map +1 -1
- package/dist/types/associations.js +1 -8
- package/dist/types/associations.js.map +1 -1
- package/dist/types/capabilities.d.ts +8 -8
- package/dist/types/capabilities.d.ts.map +1 -1
- package/dist/types/events.d.ts +3 -8
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/query.d.ts +4 -4
- package/dist/types/system.d.ts +2 -8
- package/dist/types/system.d.ts.map +1 -1
- package/dist/types/system.js.map +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +2 -1
- package/dist/validate.js.map +1 -1
- package/dist/wire/body.d.ts +32 -9
- package/dist/wire/body.d.ts.map +1 -1
- package/dist/wire/body.js +69 -8
- package/dist/wire/body.js.map +1 -1
- package/dist/wire/index.d.ts +4 -4
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +3 -3
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/record.d.ts +1 -8
- package/dist/wire/record.d.ts.map +1 -1
- package/dist/wire/record.js +0 -48
- package/dist/wire/record.js.map +1 -1
- package/dist/wire/request.d.ts +18 -18
- package/dist/wire/request.d.ts.map +1 -1
- package/dist/wire/request.js +41 -36
- package/dist/wire/request.js.map +1 -1
- package/package.json +1 -1
- package/dist/stack/identity-bindings.d.ts +0 -13
- package/dist/stack/identity-bindings.d.ts.map +0 -1
- package/dist/stack/identity-bindings.js +0 -36
- package/dist/stack/identity-bindings.js.map +0 -1
|
@@ -20,29 +20,31 @@
|
|
|
20
20
|
* refusals.
|
|
21
21
|
*
|
|
22
22
|
* Also holds the feed side of the same rule: a subscription's deliveries
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* ask the same read reach a get() does, so a feed can never disagree with
|
|
24
|
+
* a read about what a session sees.
|
|
25
25
|
*
|
|
26
26
|
* See docs/spec/access-control.md and docs/spec/events.md § Permission scoping.
|
|
27
27
|
*/
|
|
28
28
|
import { baseIdOf } from '../schema.js';
|
|
29
|
+
import { createAssociations } from '../access.js';
|
|
29
30
|
import { validatePatchValues } from '../validate.js';
|
|
30
31
|
import { ChangeEmitter, assertSinceUsable } from '../stack/changes.js';
|
|
31
32
|
import { SYSTEM_TYPES } from '../types/index.js';
|
|
32
|
-
import { StackConflictError, StackNotFoundError, StackPermissionError,
|
|
33
|
-
import { assertAssociationEdits
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import { assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidSort, filtersContent, } from '../query-validation.js';
|
|
37
|
-
import { bindingFieldsOf } from '../stack/identity-bindings.js';
|
|
33
|
+
import { StackConflictError, StackNotFoundError, StackPermissionError, StackValidationError, } from '../errors.js';
|
|
34
|
+
import { assertAssociationEdits } from '../associations/validation.js';
|
|
35
|
+
import { addsOf, associationEqual } from '../associations/identity.js';
|
|
36
|
+
import { assertQueryCapabilities, assertValidBaseIdFilter, splitQuery, filtersContent, } from '../query-validation.js';
|
|
38
37
|
import { checkFamily, storedVersionDiffers, WRITE_EXPECTATION, } from '../stack/write-expectation.js';
|
|
38
|
+
import { WRITE_GATE } from '../stack/write-gate.js';
|
|
39
39
|
import { assertAttachmentSize } from '../stack/limits.js';
|
|
40
40
|
import { validateIdTimestampSkew, validateRecordId } from '../stack/record-id.js';
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
41
|
+
import { findAppCardByDid, lookupEntityByDid } from '../stack/reads.js';
|
|
42
|
+
import { presentDeleted, withoutAuthorityChanges } from '../record-changes.js';
|
|
43
43
|
import { createCollection } from '../collection.js';
|
|
44
44
|
import { uploadContent } from '../stack/attachments.js';
|
|
45
|
+
import { assertAssociationSet, assertContentPatch, assertParentArgument, assertUnlistedArgument, } from '../stack/arguments.js';
|
|
45
46
|
import { ScopeAuthority } from './authority.js';
|
|
47
|
+
import { familyPolicy, ownerFieldRefusal } from './family-policy.js';
|
|
46
48
|
import { ScopedSubscription } from './subscription.js';
|
|
47
49
|
/**
|
|
48
50
|
* Held by Stack alone: the index re-exports ScopedStack but not this, so
|
|
@@ -63,21 +65,21 @@ export class ScopedStack {
|
|
|
63
65
|
adapter;
|
|
64
66
|
changes;
|
|
65
67
|
assertStackOpen;
|
|
66
|
-
relaysChanges;
|
|
67
68
|
migrations;
|
|
69
|
+
queryReadable;
|
|
68
70
|
/** Not callable from outside the package — see ScopedStackInit. */
|
|
69
71
|
constructor(token, init) {
|
|
70
72
|
if (token !== scopeToken) {
|
|
71
73
|
throw new TypeError('A ScopedStack is obtained through Stack.asEntity() or Stack.asActor()');
|
|
72
74
|
}
|
|
73
75
|
this.stack = init.stack;
|
|
74
|
-
this.#authority = new ScopeAuthority(init.stack, init.principalId, init.subjectId);
|
|
76
|
+
this.#authority = new ScopeAuthority(init.stack, init.principalId, init.subjectId, init.queryReadable);
|
|
75
77
|
this.idTimestampSkewMs = init.idTimestampSkewMs;
|
|
76
78
|
this.adapter = init.adapter;
|
|
77
79
|
this.changes = init.changes;
|
|
78
80
|
this.assertStackOpen = init.assertOpen;
|
|
79
|
-
this.relaysChanges = init.relaysChanges;
|
|
80
81
|
this.migrations = init.migrations;
|
|
82
|
+
this.queryReadable = init.queryReadable;
|
|
81
83
|
}
|
|
82
84
|
collection(handle) {
|
|
83
85
|
this.assertStackOpen();
|
|
@@ -106,9 +108,9 @@ export class ScopedStack {
|
|
|
106
108
|
// request, never by describing itself in the options.
|
|
107
109
|
return { actor: this.#authority.requester };
|
|
108
110
|
}
|
|
109
|
-
/** Refuse a create this request
|
|
110
|
-
async requireCreateGrant(typeId) {
|
|
111
|
-
if (!(await this.#authority.
|
|
111
|
+
/** Refuse a create outside this request's reach — see ScopeAuthority.canCreate(). */
|
|
112
|
+
async requireCreateGrant(typeId, snapshot) {
|
|
113
|
+
if (!(await this.#authority.canCreate(typeId, snapshot))) {
|
|
112
114
|
throw new StackPermissionError(`No create grant for type "${typeId}"`);
|
|
113
115
|
}
|
|
114
116
|
}
|
|
@@ -123,13 +125,10 @@ export class ScopedStack {
|
|
|
123
125
|
* the history readers, which borrow this gate without changing anything —
|
|
124
126
|
* the one way through it a `_grant` Record stays open to.
|
|
125
127
|
*/
|
|
126
|
-
async requireUpdatable(id, opts
|
|
128
|
+
async requireUpdatable(id, opts, snapshot) {
|
|
127
129
|
// The `_grant` write fence applies to mutating callers only: reading a
|
|
128
130
|
// grant Record's history is not the escalation that fence exists to stop.
|
|
129
|
-
return this.requireVerb(id,
|
|
130
|
-
fenceGrantRecord: opts.mutating ?? true,
|
|
131
|
-
expect: opts.expect,
|
|
132
|
-
});
|
|
131
|
+
return this.requireVerb(id, 'update', { fenceGrantRecord: opts.mutating ?? true, expect: opts.expect }, snapshot);
|
|
133
132
|
}
|
|
134
133
|
/**
|
|
135
134
|
* The write gate both requireUpdatable() and requireDeletable() are: the
|
|
@@ -138,41 +137,43 @@ export class ScopedStack {
|
|
|
138
137
|
* stricter management rule that no grant reaches.
|
|
139
138
|
* See docs/spec/access-control.md § Delegation: principal and subject.
|
|
140
139
|
*/
|
|
141
|
-
async requireVerb(id,
|
|
140
|
+
async requireVerb(id, verb, opts, snapshot) {
|
|
142
141
|
const record = await this.stack.get(id, { includeDeleted: true });
|
|
143
142
|
if (!record)
|
|
144
143
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
145
144
|
// Ahead of the gate, so another family's record is not found whatever
|
|
146
145
|
// this requester may do to it — the answer a collection's get() gives.
|
|
147
146
|
checkFamily(record, opts.expect);
|
|
148
|
-
|
|
149
|
-
await this.requireOwnerForGrantRecord(record);
|
|
150
|
-
if (isGroupRecord(record)) {
|
|
151
|
-
if (!this.#authority.isGroupManager(record)) {
|
|
152
|
-
throw await this.#authority.denialFor(record, `Cannot change group "${id}": only its admins can manage it`);
|
|
153
|
-
}
|
|
154
|
-
return record;
|
|
155
|
-
}
|
|
156
|
-
const subjectReaches = (await this.#authority.recordPermits(record, 'write')) ||
|
|
157
|
-
(await this.#authority.subjectAllows(record.typeId, actions, { record }));
|
|
158
|
-
const principalHolds = await this.#authority.principalAllows(record.typeId, actions);
|
|
159
|
-
if (!subjectReaches || !principalHolds) {
|
|
160
|
-
const verb = actions[0].split('-')[0];
|
|
161
|
-
const what = `Cannot ${verb} "${id}" (${record.typeId})`;
|
|
162
|
-
throw await this.#authority.denialFor(record, subjectReaches
|
|
163
|
-
? `${what}: the app acting for this entity holds no ${verb} grant`
|
|
164
|
-
: `${what}: requires ${actions.join(' or ')}`);
|
|
165
|
-
}
|
|
147
|
+
await this.gateVerb(record, verb, opts.fenceGrantRecord, snapshot);
|
|
166
148
|
return record;
|
|
167
149
|
}
|
|
150
|
+
/** requireVerb()'s gate, asked of a record already found in its family. */
|
|
151
|
+
async gateVerb(record, verb, fenceGrantRecord, snapshot) {
|
|
152
|
+
const { id } = record;
|
|
153
|
+
const { writers } = familyPolicy(record.typeId);
|
|
154
|
+
// The owner-alone fence is on writes only: reading how a grant came to
|
|
155
|
+
// be is not the escalation it exists to stop.
|
|
156
|
+
if (fenceGrantRecord || writers !== 'owner-alone') {
|
|
157
|
+
await this.requireFamilyWriter(record, snapshot);
|
|
158
|
+
}
|
|
159
|
+
// Management replaces the write bit and grants for a Group.
|
|
160
|
+
if (writers === 'group-managers')
|
|
161
|
+
return;
|
|
162
|
+
if (await this.#authority.may(verb, record, snapshot))
|
|
163
|
+
return;
|
|
164
|
+
const what = `Cannot ${verb} "${id}" (${record.typeId})`;
|
|
165
|
+
throw await this.#authority.denialFor(record, (await this.#authority.subjectMay(verb, record, snapshot))
|
|
166
|
+
? `${what}: the app acting for this entity holds no ${verb} grant`
|
|
167
|
+
: `${what}: requires ${verb}-own or ${verb}-any`, snapshot);
|
|
168
|
+
}
|
|
168
169
|
/**
|
|
169
170
|
* Fetch a record the subject can reach and the principal holds `delete` on
|
|
170
171
|
* (via permissions or a delete grant), or throw.
|
|
171
172
|
*/
|
|
172
|
-
requireDeletable(id, expect) {
|
|
173
|
+
requireDeletable(id, expect, snapshot) {
|
|
173
174
|
// No soft-delete refusal: undelete() shares this gate, and a tombstone
|
|
174
175
|
// is exactly what it addresses.
|
|
175
|
-
return this.requireVerb(id,
|
|
176
|
+
return this.requireVerb(id, 'delete', { fenceGrantRecord: true, expect }, snapshot);
|
|
176
177
|
}
|
|
177
178
|
/**
|
|
178
179
|
* Gates file-ref content fields on file access, mirroring the
|
|
@@ -181,12 +182,12 @@ export class ScopedStack {
|
|
|
181
182
|
* in `content` are checked (a content patch is a merge patch; untouched fields
|
|
182
183
|
* carry no new reference).
|
|
183
184
|
*/
|
|
184
|
-
async requireFileRefAccess(typeId, content) {
|
|
185
|
+
async requireFileRefAccess(typeId, content, snapshot) {
|
|
185
186
|
for (const field of await this.#authority.fileRefFieldNames(typeId)) {
|
|
186
187
|
const value = content[field];
|
|
187
188
|
if (typeof value !== 'string')
|
|
188
189
|
continue;
|
|
189
|
-
if (!(await this.#authority.canAccessFile(value))) {
|
|
190
|
+
if (!(await this.#authority.canAccessFile(value, snapshot))) {
|
|
190
191
|
throw new StackPermissionError(`Cannot reference file "${value}" in field "${field}": it does not exist or you cannot read it`);
|
|
191
192
|
}
|
|
192
193
|
}
|
|
@@ -198,13 +199,13 @@ export class ScopedStack {
|
|
|
198
199
|
* stricter management gate. See docs/spec/access-control.md
|
|
199
200
|
* § Reference-creation gating.
|
|
200
201
|
*/
|
|
201
|
-
async requireAssociationAccess(typeId, association) {
|
|
202
|
+
async requireAssociationAccess(typeId, association, snapshot) {
|
|
202
203
|
if (association.kind === 'attachment') {
|
|
203
|
-
if (!(await this.#authority.canAccessFile(association.fileId))) {
|
|
204
|
+
if (!(await this.#authority.canAccessFile(association.fileId, snapshot))) {
|
|
204
205
|
throw new StackPermissionError(`Cannot reference file "${association.fileId}": it does not exist or you cannot read it`);
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
|
-
else if (association.kind === 'relationship' &&
|
|
208
|
+
else if (association.kind === 'relationship' && !familyPolicy(typeId).rosterAssociations) {
|
|
208
209
|
const { target } = association;
|
|
209
210
|
// `stackUrl` is tested for a value, not for presence: absent and
|
|
210
211
|
// empty are one target — storage, targetEqual() and the filter all
|
|
@@ -212,7 +213,7 @@ export class ScopedStack {
|
|
|
212
213
|
// leave one spelling of a local Record ungated.
|
|
213
214
|
if (target.kind !== 'record' || target.stackUrl)
|
|
214
215
|
return;
|
|
215
|
-
if (!(await this.#authority.canReadReferent(target.recordId))) {
|
|
216
|
+
if (!(await this.#authority.canReadReferent(target.recordId, snapshot))) {
|
|
216
217
|
throw new StackPermissionError(`Cannot reference record "${target.recordId}": it does not exist or you cannot read it`);
|
|
217
218
|
}
|
|
218
219
|
}
|
|
@@ -229,31 +230,29 @@ export class ScopedStack {
|
|
|
229
230
|
throw new StackPermissionError('Anonymous requesters cannot create records');
|
|
230
231
|
// A grantee is exactly the untrusted actor the `id` skew check below
|
|
231
232
|
// exists to stop from forging a sort position, and a delegated app
|
|
232
|
-
// acting for the owner inherits none of the owner's extra trust
|
|
233
|
-
//
|
|
234
|
-
//
|
|
233
|
+
// acting for the owner inherits none of the owner's extra trust. Asked
|
|
234
|
+
// value-wise, since an `undefined` carries no date and Stack.create()
|
|
235
|
+
// reads it as absent.
|
|
235
236
|
if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) &&
|
|
236
237
|
!this.#authority.ownerActingAlone) {
|
|
237
238
|
throw new StackPermissionError('createdAt/updatedAt can only be set by the stack owner acting alone; a grantee or delegated create always stamps the current time.');
|
|
238
239
|
}
|
|
239
|
-
|
|
240
|
+
const snapshot = this.#authority.snapshot();
|
|
241
|
+
await this.requireCreateGrant(typeId, snapshot);
|
|
240
242
|
// The exemption is the owner's own, so delegation doesn't carry it: an
|
|
241
243
|
// owner principal acting for someone else would otherwise let that
|
|
242
244
|
// subject name any fileId and reach the bytes through the uploader
|
|
243
245
|
// clause, which matches on the subject this create stamps.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
const namesFile = familyPolicy(typeId).createNamesFile;
|
|
247
|
+
if (!this.#authority.ownerActingAlone && namesFile !== undefined) {
|
|
248
|
+
const fileId = content[namesFile];
|
|
249
|
+
if (typeof fileId !== 'string' ||
|
|
250
|
+
!(await this.#authority.hasReadableReference(fileId, snapshot))) {
|
|
247
251
|
throw new StackPermissionError(`Cannot reference file "${String(fileId)}": it does not exist or you cannot read it`);
|
|
248
252
|
}
|
|
249
253
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
if (opts.unlisted && !this.#authority.mayGrantAccess()) {
|
|
254
|
-
throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
|
|
255
|
-
}
|
|
256
|
-
this.requireOwnerForOwnerDid(typeId, content.did);
|
|
254
|
+
await this.requireAuthorityAtCreate(typeId, opts, snapshot);
|
|
255
|
+
this.requireOwnerFields(typeId, undefined, content);
|
|
257
256
|
await this.requireAppIdMatchesPrincipal(opts.appId);
|
|
258
257
|
if (opts.id !== undefined) {
|
|
259
258
|
validateRecordId(opts.id);
|
|
@@ -264,13 +263,14 @@ export class ScopedStack {
|
|
|
264
263
|
validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
|
|
265
264
|
}
|
|
266
265
|
}
|
|
267
|
-
if (opts.parentId !== undefined &&
|
|
266
|
+
if (opts.parentId !== undefined &&
|
|
267
|
+
!(await this.#authority.canReadReferent(opts.parentId, snapshot))) {
|
|
268
268
|
throw new StackPermissionError(`Cannot reference record "${opts.parentId}" as parent: it does not exist or you cannot read it`);
|
|
269
269
|
}
|
|
270
270
|
for (const assoc of opts.associations ?? []) {
|
|
271
|
-
await this.requireAssociationAccess(typeId, assoc);
|
|
271
|
+
await this.requireAssociationAccess(typeId, assoc, snapshot);
|
|
272
272
|
}
|
|
273
|
-
await this.requireFileRefAccess(typeId, content);
|
|
273
|
+
await this.requireFileRefAccess(typeId, content, snapshot);
|
|
274
274
|
return this.stack.create(typeId, content, {
|
|
275
275
|
...opts,
|
|
276
276
|
createdBy: this.#authority.requester,
|
|
@@ -286,14 +286,14 @@ export class ScopedStack {
|
|
|
286
286
|
const record = await this.stack.get(id, opts);
|
|
287
287
|
if (!record)
|
|
288
288
|
return null;
|
|
289
|
-
if (!(await this.#authority.canRead(record)))
|
|
289
|
+
if (!(await this.#authority.canRead(record, this.#authority.snapshot())))
|
|
290
290
|
return null;
|
|
291
291
|
return presentDeleted(record);
|
|
292
292
|
}
|
|
293
293
|
/**
|
|
294
|
-
* Runs through this.query(), so
|
|
295
|
-
* question the caller asks on its own behalf. includeUnlisted
|
|
296
|
-
* only when the request is the owner acting alone, per
|
|
294
|
+
* Runs through this.query(), so the read reach still applies — this
|
|
295
|
+
* answers a question the caller asks on its own behalf. includeUnlisted
|
|
296
|
+
* is passed only when the request is the owner acting alone, per
|
|
297
297
|
* docs/spec/identity.md § DID bindings.
|
|
298
298
|
*/
|
|
299
299
|
async getEntityByDid(did) {
|
|
@@ -303,177 +303,195 @@ export class ScopedStack {
|
|
|
303
303
|
return this.getEntityByDid(this.stack.ownerEntityId);
|
|
304
304
|
}
|
|
305
305
|
/**
|
|
306
|
-
* Query records,
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
306
|
+
* Query records, among those this request can read: the adapter matches
|
|
307
|
+
* the request's read reach as one more condition of the query, so a page,
|
|
308
|
+
* its cursor and whether one comes back are decided among readable
|
|
309
|
+
* Records alone. See docs/spec/disclosure.md § What a query reveals.
|
|
310
310
|
*/
|
|
311
311
|
async query(query = {}) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
assertValidAssociationFilters(query.filter);
|
|
315
|
-
assertValidBaseIdFilter(query.filter);
|
|
312
|
+
const split = splitQuery(query);
|
|
313
|
+
assertQueryCapabilities(split.query, this.stack.capabilities);
|
|
316
314
|
if (query.filter?.includeUnlisted && !this.#authority.ownerActingAlone) {
|
|
317
315
|
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
318
316
|
}
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
343
|
-
return { records, cursor: page.cursor };
|
|
344
|
-
}
|
|
345
|
-
/**
|
|
346
|
-
* Apply a change set on behalf of the subject. Each key is gated against
|
|
347
|
-
* the record as it stands, never as the set would leave it, and one
|
|
348
|
-
* refused key refuses the call. See docs/spec/access-control.md
|
|
349
|
-
* § Composing a change set.
|
|
350
|
-
*/
|
|
351
|
-
async mutate(id, changes, opts = {}) {
|
|
352
|
-
// Ahead of every gate: a malformed change set is a validation error
|
|
353
|
-
// for every requester, rather than one for the owner and a permission
|
|
354
|
-
// refusal for everyone else.
|
|
355
|
-
assertNonEmptyChangeSet(changes);
|
|
356
|
-
if (changes.contentPatch) {
|
|
357
|
-
const patchErrors = validatePatchValues(changes.contentPatch);
|
|
358
|
-
if (patchErrors.length > 0)
|
|
359
|
-
throw new StackValidationError(patchErrors);
|
|
360
|
-
}
|
|
361
|
-
// Refused here as well as in `Stack` below, so the partition is a
|
|
362
|
-
// refusal rather than a permission question: an `associations` key
|
|
363
|
-
// naming an authority kind is the wrong surface for every requester,
|
|
364
|
-
// owner included.
|
|
365
|
-
if (changes.associations)
|
|
366
|
-
assertDataAssociations(changes.associations, 'associations');
|
|
367
|
-
if (changes.permissions)
|
|
368
|
-
assertAuthorityAssociations(changes.permissions, 'permissions');
|
|
369
|
-
const writes = changes.contentPatch !== undefined ||
|
|
370
|
-
changes.associations !== undefined ||
|
|
371
|
-
changes.parentId !== undefined;
|
|
372
|
-
// `unlisted` reshares on presence alone; a restated `permissions` set
|
|
373
|
-
// does not, which permissionsMove() decides once the record is in hand.
|
|
374
|
-
// See docs/spec/access-control.md § Storage unifies; the API does not.
|
|
375
|
-
const unlists = changes.unlisted !== undefined;
|
|
376
|
-
// requireUpdatable() reads the record and applies the write gate; the
|
|
377
|
-
// reshare keys need the record before their own gate, and a change set
|
|
378
|
-
// carrying only those must not be held to the write gate it doesn't
|
|
379
|
-
// need. One read either way.
|
|
380
|
-
const record = writes
|
|
381
|
-
? await this.requireUpdatable(id, { expect: opts })
|
|
382
|
-
: await this.requireReshareable(id, opts);
|
|
383
|
-
// A key the gate read as inert is dropped, not forwarded: `Stack`
|
|
384
|
-
// recomputes the delta from its own read, and must not write an ACL on
|
|
385
|
-
// the strength of this one. See docs/spec/access-control.md
|
|
386
|
-
// § Storage unifies; the API does not.
|
|
387
|
-
let authorized = changes;
|
|
388
|
-
if (writes) {
|
|
389
|
-
const moves = this.permissionsMove(record, changes);
|
|
390
|
-
if (unlists || moves)
|
|
391
|
-
await this.requireReshareOf(record);
|
|
392
|
-
if (changes.permissions !== undefined && !moves && !this.#authority.canReshare(record)) {
|
|
393
|
-
const { permissions: _inert, ...rest } = changes;
|
|
394
|
-
authorized = rest;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
317
|
+
const reach = await this.#authority.snapshot().reach('read');
|
|
318
|
+
const page = await this.queryReadable(split, reach);
|
|
319
|
+
// Projected here as well as in get(), or `includeDeleted` would be a
|
|
320
|
+
// strictly better read channel than the fetch-by-ID it is supposed to
|
|
321
|
+
// match.
|
|
322
|
+
return { records: page.records.map(presentDeleted), cursor: page.cursor };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Patch content on behalf of the subject, on the write gate, plus the
|
|
326
|
+
* gates a content value can carry: a card's binding fields and the files
|
|
327
|
+
* a `file-ref` field names. See docs/spec/access-control.md § Per-verb gates.
|
|
328
|
+
*/
|
|
329
|
+
async patchContent(id, patch, opts = {}) {
|
|
330
|
+
// Every argument check sits ahead of the gate, here and in each write
|
|
331
|
+
// verb below, so a malformed call earns one refusal from every requester.
|
|
332
|
+
assertContentPatch(patch);
|
|
333
|
+
const patchErrors = validatePatchValues(patch);
|
|
334
|
+
if (patchErrors.length > 0)
|
|
335
|
+
throw new StackValidationError(patchErrors);
|
|
336
|
+
const snapshot = this.#authority.snapshot();
|
|
337
|
+
const record = await this.requireUpdatable(id, { expect: opts }, snapshot);
|
|
397
338
|
// A patch for another version is refused by `Stack`, which owes the
|
|
398
339
|
// tombstone and `ifVersion` refusals first; the content gates would read
|
|
399
340
|
// a stored Type it was not written for. Pinned to this read, `Stack`
|
|
400
341
|
// cannot find a record the patch lands on.
|
|
401
|
-
const mismatched = storedVersionDiffers(record, opts,
|
|
402
|
-
if (
|
|
403
|
-
const patch = changes.contentPatch;
|
|
342
|
+
const mismatched = storedVersionDiffers(record, opts, true);
|
|
343
|
+
if (!mismatched) {
|
|
404
344
|
// Value-wise, not presence-wise: a client that reads a card, edits
|
|
405
345
|
// its `name` and sends the whole content object back is not setting
|
|
406
346
|
// the binding it round-trips, and `name` is writable by record-level
|
|
407
|
-
// permission.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if ('did' in patch && patch.did !== record.content.did) {
|
|
412
|
-
this.requireOwnerForOwnerDid(record.typeId, patch.did);
|
|
413
|
-
}
|
|
414
|
-
await this.requireFileRefAccess(record.typeId, patch);
|
|
347
|
+
// permission.
|
|
348
|
+
const content = record.content;
|
|
349
|
+
this.requireOwnerFields(record.typeId, content, { ...content, ...patch });
|
|
350
|
+
await this.requireFileRefAccess(record.typeId, patch, snapshot);
|
|
415
351
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
352
|
+
const ifVersion = mismatched ? (opts.ifVersion ?? record.version) : opts.ifVersion;
|
|
353
|
+
return this.stack.patchContent(id, patch, { ...opts, ifVersion, ...this.actor });
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The write gate, plus read access to the destination: a container is a
|
|
357
|
+
* reference the requester has to be allowed to create.
|
|
358
|
+
*/
|
|
359
|
+
async setParent(id, parentId, opts = {}) {
|
|
360
|
+
assertParentArgument(parentId);
|
|
361
|
+
const snapshot = this.#authority.snapshot();
|
|
362
|
+
const record = await this.requireUpdatable(id, { expect: opts }, snapshot);
|
|
363
|
+
if (typeof parentId === 'string' &&
|
|
364
|
+
parentId !== (record.parentId ?? null) &&
|
|
365
|
+
!(await this.#authority.canReadReferent(parentId, snapshot))) {
|
|
366
|
+
throw new StackPermissionError(`Cannot reference record "${parentId}" as parent: it does not exist or you cannot read it`);
|
|
423
367
|
}
|
|
424
|
-
|
|
425
|
-
|
|
368
|
+
return this.stack.setParent(id, parentId, { ...opts, ...this.actor });
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Listing decides who can enumerate a record, so it carries the reshare
|
|
372
|
+
* gate rather than the write bit. See docs/spec/unlisted.md.
|
|
373
|
+
*/
|
|
374
|
+
async setUnlisted(id, unlisted, opts = {}) {
|
|
375
|
+
assertUnlistedArgument(unlisted);
|
|
376
|
+
await this.requireReshareable(id, opts, this.#authority.snapshot());
|
|
377
|
+
return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor });
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The write gate, plus the reference gate on each add new to the record:
|
|
381
|
+
* a remove, or an add of an identity already held, names a reference the
|
|
382
|
+
* record carries, so it is gated by the write bit alone.
|
|
383
|
+
*/
|
|
384
|
+
async editAssociations(id, edits, opts = {}) {
|
|
385
|
+
assertAssociationEdits(edits, 'editAssociations()', 'data');
|
|
386
|
+
const snapshot = this.#authority.snapshot();
|
|
387
|
+
return this.stack.editAssociations(id, edits, {
|
|
388
|
+
...opts,
|
|
389
|
+
...this.actor,
|
|
390
|
+
[WRITE_GATE]: (record) => this.gateAssociationWrite(record, addsOf(edits), snapshot),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/** editAssociations()'s gates, with every element new to the set an add. */
|
|
394
|
+
async setAssociations(id, associations, opts = {}) {
|
|
395
|
+
assertAssociationSet(associations, 'associations');
|
|
396
|
+
const snapshot = this.#authority.snapshot();
|
|
397
|
+
return this.stack.setAssociations(id, associations, {
|
|
398
|
+
...opts,
|
|
399
|
+
...this.actor,
|
|
400
|
+
[WRITE_GATE]: (record) => this.gateAssociationWrite(record, associations, snapshot),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The write gate, then the reference gate over the elements a write adds,
|
|
405
|
+
* asked against the stored set so an association the record already
|
|
406
|
+
* carries is not re-gated. `Stack` asks it of each read it writes from,
|
|
407
|
+
* so a retried write is gated on the set it lands on.
|
|
408
|
+
*/
|
|
409
|
+
async gateAssociationWrite(record, added, snapshot) {
|
|
410
|
+
await this.gateVerb(record, 'update', true, snapshot);
|
|
411
|
+
for (const association of added) {
|
|
412
|
+
if ((record.associations ?? []).some((a) => associationEqual(a, association)))
|
|
413
|
+
continue;
|
|
414
|
+
await this.requireAssociationAccess(record.typeId, association, snapshot);
|
|
426
415
|
}
|
|
427
|
-
const ifVersion = mismatched ? (opts.ifVersion ?? record.version) : opts.ifVersion;
|
|
428
|
-
return this.stack.mutate(id, authorized, { ...opts, ifVersion, ...this.actor });
|
|
429
416
|
}
|
|
430
|
-
|
|
431
|
-
|
|
417
|
+
/**
|
|
418
|
+
* Extend or withdraw who reaches a record, on the reshare gate. The write
|
|
419
|
+
* bit does not confer it, or a write-holder could decide who else reaches
|
|
420
|
+
* the record. See docs/spec/access-control.md § Record-level permissions.
|
|
421
|
+
*/
|
|
422
|
+
async editAccess(id, edits, opts = {}) {
|
|
423
|
+
assertAssociationEdits(edits, 'editAccess()', 'authority');
|
|
424
|
+
const snapshot = this.#authority.snapshot();
|
|
425
|
+
return this.stack.editAccess(id, edits, {
|
|
426
|
+
...opts,
|
|
427
|
+
...this.actor,
|
|
428
|
+
[WRITE_GATE]: (record) => this.gateReshare(record, snapshot),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
/** See editAccess() — the same gate. */
|
|
432
|
+
async setAccess(id, permissions, opts = {}) {
|
|
433
|
+
assertAssociationSet(permissions, 'permissions');
|
|
434
|
+
const snapshot = this.#authority.snapshot();
|
|
435
|
+
return this.stack.setAccess(id, permissions, {
|
|
436
|
+
...opts,
|
|
437
|
+
...this.actor,
|
|
438
|
+
[WRITE_GATE]: (record) => this.gateReshare(record, snapshot),
|
|
439
|
+
});
|
|
432
440
|
}
|
|
433
441
|
/**
|
|
434
442
|
* The record, having established that this request may decide who else
|
|
435
443
|
* reaches it — the owner-or-creator rule, asked of both sides of a
|
|
436
|
-
* delegation, or a Group's admin rule for a `_group`. The gate the
|
|
437
|
-
*
|
|
438
|
-
* deliberately does not confer.
|
|
444
|
+
* delegation, or a Group's admin rule for a `_group`. The gate the access
|
|
445
|
+
* and listing verbs carry, which the write bit deliberately does not confer.
|
|
439
446
|
*/
|
|
440
|
-
async requireReshareable(id, expect) {
|
|
447
|
+
async requireReshareable(id, expect, snapshot) {
|
|
441
448
|
const record = await this.stack.get(id, { includeDeleted: true });
|
|
442
449
|
if (!record)
|
|
443
450
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
444
451
|
checkFamily(record, expect);
|
|
445
|
-
await this.
|
|
446
|
-
await this.requireReshareOf(record);
|
|
452
|
+
await this.gateReshare(record, snapshot);
|
|
447
453
|
return record;
|
|
448
454
|
}
|
|
449
455
|
/**
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
456
|
+
* Authority a create writes — a Group's roster, the Record's permissions
|
|
457
|
+
* and listing — gated as a later write of it would be, asked of the
|
|
458
|
+
* Record the create would make. That Record carries none of the authority
|
|
459
|
+
* being written, so no call can satisfy its own gate.
|
|
460
|
+
* See docs/spec/access-control.md § Write implies read.
|
|
461
|
+
*/
|
|
462
|
+
async requireAuthorityAtCreate(typeId, opts, snapshot) {
|
|
463
|
+
const createdBy = this.#authority.requester;
|
|
464
|
+
const record = {
|
|
465
|
+
typeId,
|
|
466
|
+
createdBy,
|
|
467
|
+
associations: createAssociations(typeId, undefined, createdBy, this.stack.ownerEntityId),
|
|
468
|
+
};
|
|
469
|
+
if (!this.#authority.passesWriterFence(record)) {
|
|
470
|
+
throw new StackPermissionError(writerFenceRefusal(typeId));
|
|
471
|
+
}
|
|
472
|
+
if ((opts.permissions?.length || opts.unlisted) &&
|
|
473
|
+
!(await this.#authority.canReshare(record, snapshot))) {
|
|
474
|
+
throw new StackPermissionError(`Cannot set permissions or listing on a new ${typeId} record: only its author or the stack owner can decide who else reaches it`);
|
|
475
|
+
}
|
|
458
476
|
}
|
|
459
|
-
/**
|
|
460
|
-
async
|
|
461
|
-
|
|
462
|
-
|
|
477
|
+
/** requireReshareable()'s gate, asked of a record already found in its family. */
|
|
478
|
+
async gateReshare(record, snapshot) {
|
|
479
|
+
await this.requireFamilyWriter(record, snapshot);
|
|
480
|
+
if (!(await this.#authority.canReshare(record, snapshot))) {
|
|
481
|
+
throw await this.#authority.denialFor(record, `Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`, snapshot);
|
|
463
482
|
}
|
|
464
483
|
}
|
|
465
484
|
/**
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
* See docs/spec/identity.md § DID bindings.
|
|
485
|
+
* Refuse a write moving content its family reserves to the owner acting
|
|
486
|
+
* alone — an `_app` card's bindings, the owner's own DID on an `_entity`
|
|
487
|
+
* — whichever verb carries it. See docs/spec/identity.md § DID bindings.
|
|
470
488
|
*/
|
|
471
|
-
|
|
472
|
-
if (
|
|
473
|
-
return;
|
|
474
|
-
if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
|
|
489
|
+
requireOwnerFields(typeId, before, after) {
|
|
490
|
+
if (this.#authority.ownerActingAlone)
|
|
475
491
|
return;
|
|
476
|
-
|
|
492
|
+
const refusal = ownerFieldRefusal(typeId, before, after, this.stack.ownerEntityId);
|
|
493
|
+
if (refusal)
|
|
494
|
+
throw new StackPermissionError(refusal);
|
|
477
495
|
}
|
|
478
496
|
/**
|
|
479
497
|
* A delegated write's `appId` must match the `_app` card the owner
|
|
@@ -491,79 +509,14 @@ export class ScopedStack {
|
|
|
491
509
|
}
|
|
492
510
|
}
|
|
493
511
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
512
|
+
* Refuse a write its family fences off — see
|
|
513
|
+
* ScopeAuthority.passesWriterFence() — with the refusal an unreadable
|
|
514
|
+
* Record answers with.
|
|
497
515
|
*/
|
|
498
|
-
|
|
499
|
-
if (
|
|
516
|
+
async requireFamilyWriter(record, snapshot) {
|
|
517
|
+
if (this.#authority.passesWriterFence(record))
|
|
500
518
|
return;
|
|
501
|
-
|
|
502
|
-
return;
|
|
503
|
-
this.requireOwnerActingAlone('Only the stack owner may claim the owner’s own did');
|
|
504
|
-
}
|
|
505
|
-
/**
|
|
506
|
-
* `_grant` and `_install` Records are authority, so only the owner acting
|
|
507
|
-
* alone may write one; reading them stays on the ordinary gate.
|
|
508
|
-
* See docs/spec/access-control.md § What a grant covers.
|
|
509
|
-
*/
|
|
510
|
-
async requireOwnerForGrantRecord(record) {
|
|
511
|
-
const family = baseIdOf(record.typeId);
|
|
512
|
-
if (family !== SYSTEM_TYPES.GRANT && family !== SYSTEM_TYPES.INSTALL)
|
|
513
|
-
return;
|
|
514
|
-
if (this.#authority.ownerActingAlone)
|
|
515
|
-
return;
|
|
516
|
-
throw await this.#authority.denialFor(record, `Only the stack owner may write a ${family} record`);
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* Gated on the write bit alone, which is why the list is held to its
|
|
520
|
-
* rules first: authority reached through this verb would be exactly the
|
|
521
|
-
* escalation the partition exists to stop, decided before any record is
|
|
522
|
-
* read so it cannot depend on who is asking.
|
|
523
|
-
*/
|
|
524
|
-
async associate(id, associations, opts = {}) {
|
|
525
|
-
assertAssociationList(associations, 'associate()', 'associations', 'data');
|
|
526
|
-
return this.amendAssociations(id, editsOf('add', associations), opts, 'associate()');
|
|
527
|
-
}
|
|
528
|
-
/** See associate() — the same write gate, the same kind refusal. */
|
|
529
|
-
async dissociate(id, associations, opts = {}) {
|
|
530
|
-
assertAssociationList(associations, 'dissociate()', 'associations', 'data');
|
|
531
|
-
return this.amendAssociations(id, editsOf('remove', associations), opts, 'dissociate()');
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* The write gate, plus the per-element reference gate on each add: what a
|
|
535
|
-
* remove names is already on the record, so it is gated by the write bit
|
|
536
|
-
* alone. See associate().
|
|
537
|
-
*/
|
|
538
|
-
async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
|
|
539
|
-
assertAssociationEdits(changes, surface, 'data');
|
|
540
|
-
const record = await this.requireUpdatable(id, { expect: opts });
|
|
541
|
-
for (const change of changes) {
|
|
542
|
-
if (change.op === 'add')
|
|
543
|
-
await this.requireAssociationAccess(record.typeId, change.association);
|
|
544
|
-
}
|
|
545
|
-
return this.stack.amendAssociations(id, changes, { ...opts, ...this.actor }, surface);
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Extend who reaches a record, on the reshare gate the `permissions` key
|
|
549
|
-
* carries. The write bit does not confer it, or a write-holder could
|
|
550
|
-
* decide who else reaches the record.
|
|
551
|
-
* See docs/spec/access-control.md § Record-level permissions.
|
|
552
|
-
*/
|
|
553
|
-
async grantAccess(id, permissions, opts = {}) {
|
|
554
|
-
assertAssociationList(permissions, 'grantAccess()', 'permissions', 'authority');
|
|
555
|
-
return this.amendAccess(id, editsOf('add', permissions), opts, 'grantAccess()');
|
|
556
|
-
}
|
|
557
|
-
/** Withdraw elements of who reaches a record — see grantAccess(). */
|
|
558
|
-
async revokeAccess(id, permissions, opts = {}) {
|
|
559
|
-
assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
|
|
560
|
-
return this.amendAccess(id, editsOf('remove', permissions), opts, 'revokeAccess()');
|
|
561
|
-
}
|
|
562
|
-
/** The reshare gate over an edit list — see grantAccess(). */
|
|
563
|
-
async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
|
|
564
|
-
assertAssociationEdits(changes, surface, 'authority');
|
|
565
|
-
await this.requireReshareable(id, opts);
|
|
566
|
-
return this.stack.amendAccess(id, changes, { ...opts, ...this.actor }, surface);
|
|
519
|
+
throw await this.#authority.denialFor(record, writerFenceRefusal(record.typeId, record.id), snapshot);
|
|
567
520
|
}
|
|
568
521
|
/**
|
|
569
522
|
* Purge is owner-only: it is irreversible and destroys version
|
|
@@ -572,31 +525,24 @@ export class ScopedStack {
|
|
|
572
525
|
* limited to soft delete.
|
|
573
526
|
*/
|
|
574
527
|
async delete(id, opts = {}) {
|
|
575
|
-
const
|
|
576
|
-
return { referencedFileIds };
|
|
577
|
-
}
|
|
578
|
-
/**
|
|
579
|
-
* delete(), plus the record it acted on. Gated identically — the
|
|
580
|
-
* existence/visibility check that produces a 404 ahead of the 403
|
|
581
|
-
* permission gate still runs via requireDeletable() before either kind
|
|
582
|
-
* of delete, so this changes what a successful call reports, not the
|
|
583
|
-
* disclosure ordering a refused one follows.
|
|
584
|
-
*/
|
|
585
|
-
async deleteAndReturn(id, opts = {}) {
|
|
586
|
-
const record = await this.requireDeletable(id, opts);
|
|
528
|
+
const record = await this.requireDeletable(id, opts, this.#authority.snapshot());
|
|
587
529
|
if (opts.purge && !this.#authority.ownerActingAlone) {
|
|
588
530
|
throw new StackPermissionError('Purge is owner-only');
|
|
589
531
|
}
|
|
590
|
-
if (!opts[WRITE_EXPECTATION])
|
|
591
|
-
return this.stack.deleteAndReturn(id, { ...opts, ...this.actor });
|
|
592
532
|
// Checked against the read above, which the delete is pinned to.
|
|
593
533
|
// Forwarded, it would cost a purge the read `Stack` otherwise skips.
|
|
594
|
-
const { [WRITE_EXPECTATION]:
|
|
595
|
-
|
|
534
|
+
const { [WRITE_EXPECTATION]: expectation, ...rest } = opts;
|
|
535
|
+
const result = await this.stack.delete(id, {
|
|
596
536
|
...rest,
|
|
597
|
-
ifVersion: opts.ifVersion ?? record.version,
|
|
537
|
+
...(expectation && { ifVersion: opts.ifVersion ?? record.version }),
|
|
598
538
|
...this.actor,
|
|
599
539
|
});
|
|
540
|
+
// A soft delete's record reaches the caller as every scoped read
|
|
541
|
+
// presents one; a purge is the owner's, and reports what it destroyed.
|
|
542
|
+
// See docs/spec/versioning.md § The tombstone is literal.
|
|
543
|
+
return opts.purge || !result.record
|
|
544
|
+
? result
|
|
545
|
+
: { ...result, record: presentDeleted(result.record) };
|
|
600
546
|
}
|
|
601
547
|
/**
|
|
602
548
|
* Reverse a soft delete. Gated the same as delete() — undelete is the
|
|
@@ -604,7 +550,7 @@ export class ScopedStack {
|
|
|
604
550
|
* would be backwards. Idempotent, per Stack.undelete().
|
|
605
551
|
*/
|
|
606
552
|
async undelete(id, opts = {}) {
|
|
607
|
-
await this.requireDeletable(id, opts);
|
|
553
|
+
await this.requireDeletable(id, opts, this.#authority.snapshot());
|
|
608
554
|
return this.stack.undelete(id, { ...opts, ...this.actor });
|
|
609
555
|
}
|
|
610
556
|
/**
|
|
@@ -615,7 +561,7 @@ export class ScopedStack {
|
|
|
615
561
|
* stop. See docs/spec/versioning.md § History access.
|
|
616
562
|
*/
|
|
617
563
|
async getVersions(id, query = {}) {
|
|
618
|
-
await this.requireUpdatable(id, { mutating: false });
|
|
564
|
+
await this.requireUpdatable(id, { mutating: false }, this.#authority.snapshot());
|
|
619
565
|
return this.stack.getVersions(id, query);
|
|
620
566
|
}
|
|
621
567
|
/**
|
|
@@ -624,13 +570,16 @@ export class ScopedStack {
|
|
|
624
570
|
* See docs/spec/journal.md § Reading it.
|
|
625
571
|
*/
|
|
626
572
|
async getJournal(id, query = {}) {
|
|
627
|
-
const
|
|
573
|
+
const snapshot = this.#authority.snapshot();
|
|
574
|
+
const record = await this.requireUpdatable(id, { mutating: false }, snapshot);
|
|
628
575
|
const entries = await this.stack.getJournal(id, query);
|
|
629
|
-
return this.#authority.canReshare(record
|
|
576
|
+
return (await this.#authority.canReshare(record, snapshot))
|
|
577
|
+
? entries
|
|
578
|
+
: entries.map(withoutAuthorityChanges);
|
|
630
579
|
}
|
|
631
580
|
/** See getVersions() — the same mutate-surface gate. */
|
|
632
581
|
async getVersion(id, version) {
|
|
633
|
-
await this.requireUpdatable(id, { mutating: false });
|
|
582
|
+
await this.requireUpdatable(id, { mutating: false }, this.#authority.snapshot());
|
|
634
583
|
return this.stack.getVersion(id, version);
|
|
635
584
|
}
|
|
636
585
|
/**
|
|
@@ -641,15 +590,15 @@ export class ScopedStack {
|
|
|
641
590
|
* restore would widen. See docs/spec/versioning.md § Restore semantics.
|
|
642
591
|
*/
|
|
643
592
|
async restoreVersion(id, version, opts = {}) {
|
|
644
|
-
const
|
|
593
|
+
const snapshot = this.#authority.snapshot();
|
|
594
|
+
const record = await this.requireUpdatable(id, { expect: opts }, snapshot);
|
|
645
595
|
if (!this.#authority.ownerActingAlone) {
|
|
646
596
|
const target = await this.stack.getVersion(id, version);
|
|
647
597
|
if (target) {
|
|
648
|
-
// A rollback
|
|
649
|
-
//
|
|
650
|
-
this.
|
|
651
|
-
|
|
652
|
-
await this.requireFileRefAccess(target.typeId, target.content);
|
|
598
|
+
// A rollback moving a reserved value is the same trust decision a
|
|
599
|
+
// content patch reserves to the owner, reached by another route.
|
|
600
|
+
this.requireOwnerFields(record.typeId, record.content, target.content);
|
|
601
|
+
await this.requireFileRefAccess(target.typeId, target.content, snapshot);
|
|
653
602
|
// Content's file refs are the only reference a restore can put
|
|
654
603
|
// back: containment and associations are not on a snapshot at all,
|
|
655
604
|
// so a restore never introduces either — see
|
|
@@ -665,13 +614,15 @@ export class ScopedStack {
|
|
|
665
614
|
* See docs/spec/access-control.md § What a grant covers.
|
|
666
615
|
*/
|
|
667
616
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
668
|
-
if (!this.#authority.ownerActingAlone)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
617
|
+
if (!this.#authority.ownerActingAlone) {
|
|
618
|
+
const snapshot = this.#authority.snapshot();
|
|
619
|
+
const record = await this.requireInstallMigration(id, toTypeId, snapshot);
|
|
620
|
+
this.requireOwnerFields(toTypeId, record.content, content);
|
|
621
|
+
// The new content is a fresh set of file references, so an app is held
|
|
622
|
+
// to the gate create() applies; otherwise a migration could point one
|
|
623
|
+
// of its records at any attachment and read the bytes through it.
|
|
624
|
+
await this.requireFileRefAccess(toTypeId, content, snapshot);
|
|
625
|
+
}
|
|
675
626
|
return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
|
|
676
627
|
}
|
|
677
628
|
/**
|
|
@@ -681,14 +632,15 @@ export class ScopedStack {
|
|
|
681
632
|
* owner may migrate a tombstone; an app sees it without content.
|
|
682
633
|
* See docs/spec/apps.md § Migrating an installed app's types.
|
|
683
634
|
*/
|
|
684
|
-
async requireInstallMigration(id, toTypeId) {
|
|
635
|
+
async requireInstallMigration(id, toTypeId, snapshot) {
|
|
685
636
|
const record = await this.stack.get(id, { includeDeleted: true });
|
|
686
|
-
if (!record || !(await this.#authority.installMayMigrate(record, toTypeId))) {
|
|
637
|
+
if (!record || !(await this.#authority.installMayMigrate(record, toTypeId, snapshot))) {
|
|
687
638
|
throw new StackPermissionError('Only the stack owner, or an installed app within the type families it defines, may commit a migration');
|
|
688
639
|
}
|
|
689
640
|
if (record.deletedAt) {
|
|
690
641
|
throw new StackConflictError(`Record "${id}" is soft-deleted; undelete it before migrating it.`);
|
|
691
642
|
}
|
|
643
|
+
return record;
|
|
692
644
|
}
|
|
693
645
|
/**
|
|
694
646
|
* Store bytes and create an _attachment@1 metadata record (create grant
|
|
@@ -705,7 +657,7 @@ export class ScopedStack {
|
|
|
705
657
|
if (!principal) {
|
|
706
658
|
throw new StackPermissionError('Anonymous requesters cannot upload attachments');
|
|
707
659
|
}
|
|
708
|
-
await this.requireCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1
|
|
660
|
+
await this.requireCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`, this.#authority.snapshot());
|
|
709
661
|
await this.requireAppIdMatchesPrincipal(appId);
|
|
710
662
|
assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
|
|
711
663
|
const fileId = await this.adapter.putBlob(data);
|
|
@@ -717,7 +669,7 @@ export class ScopedStack {
|
|
|
717
669
|
* predicate as the reference-creation gate (canAccessFile).
|
|
718
670
|
*/
|
|
719
671
|
async getAttachment(fileId) {
|
|
720
|
-
if (!(await this.#authority.canAccessFile(fileId))) {
|
|
672
|
+
if (!(await this.#authority.canAccessFile(fileId, this.#authority.snapshot()))) {
|
|
721
673
|
throw new StackPermissionError(`Cannot read file "${fileId}": it does not exist or you cannot read it`);
|
|
722
674
|
}
|
|
723
675
|
return this.stack.getAttachment(fileId);
|
|
@@ -740,37 +692,27 @@ export class ScopedStack {
|
|
|
740
692
|
}
|
|
741
693
|
/**
|
|
742
694
|
* Observe the changes this request may read, decided per event by the
|
|
743
|
-
* same
|
|
695
|
+
* same read reach that get() and query() ask. An unreadable record yields
|
|
744
696
|
* no event at all, since a change's existence is itself a disclosure.
|
|
745
697
|
* See docs/spec/events.md § Permission scoping.
|
|
746
698
|
*/
|
|
747
699
|
async subscribe(handler, opts = {}) {
|
|
748
700
|
this.assertStackOpen();
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
|
|
752
|
-
'no record to re-check. Subscribe with a session-scoped stack instead.');
|
|
753
|
-
}
|
|
754
|
-
// A relaying stack was already refused above, so relaysChanges is
|
|
755
|
-
// always false here — `since` never has a cursor to mean anything by.
|
|
701
|
+
// A scoped view exists only over a stack that relays nothing, so
|
|
702
|
+
// `since` never has a cursor to mean anything by.
|
|
756
703
|
assertSinceUsable(opts.since, false);
|
|
757
704
|
assertValidBaseIdFilter(opts.filter);
|
|
758
|
-
if (opts.includeUnlisted && !this.#authority.ownerActingAlone) {
|
|
705
|
+
if (opts.filter?.includeUnlisted && !this.#authority.ownerActingAlone) {
|
|
759
706
|
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
760
707
|
}
|
|
761
|
-
return this.changes.add(new ScopedSubscription((record,
|
|
708
|
+
return this.changes.add(new ScopedSubscription(this.#authority.snapshot(), (record, snapshot) => this.#authority.canRead(record, snapshot), handler, opts));
|
|
762
709
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
*/
|
|
769
|
-
async canReadCached(record, cache) {
|
|
770
|
-
const grants = this.#authority.identified
|
|
771
|
-
? await cache.grants(() => this.#authority.loadGrants())
|
|
772
|
-
: undefined;
|
|
773
|
-
return this.#authority.canRead(record, grants, cache.roles);
|
|
710
|
+
}
|
|
711
|
+
/** Why a family fence refused a write: to `id`, or to a new Record when absent. */
|
|
712
|
+
function writerFenceRefusal(typeId, id) {
|
|
713
|
+
if (familyPolicy(typeId).writers === 'group-managers') {
|
|
714
|
+
return `Cannot ${id ? `change group "${id}"` : 'create this group'}: only its admins can manage it`;
|
|
774
715
|
}
|
|
716
|
+
return `Only the stack owner may write a ${baseIdOf(typeId)} record`;
|
|
775
717
|
}
|
|
776
718
|
//# sourceMappingURL=scoped-stack.js.map
|