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