@haverstack/core 0.29.0 → 0.31.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/dist/adapter-entry.d.ts +2 -2
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +1 -1
- package/dist/adapter-entry.js.map +1 -1
- package/dist/changes.d.ts +14 -0
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +35 -0
- package/dist/changes.js.map +1 -1
- package/dist/errors.d.ts +191 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +219 -0
- package/dist/errors.js.map +1 -0
- package/dist/grants.d.ts +84 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +157 -0
- package/dist/grants.js.map +1 -0
- package/dist/id.d.ts +7 -5
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +17 -15
- package/dist/id.js.map +1 -1
- package/dist/identity-bindings.d.ts +13 -0
- package/dist/identity-bindings.d.ts.map +1 -0
- package/dist/identity-bindings.js +42 -0
- package/dist/identity-bindings.js.map +1 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +23 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +35 -0
- package/dist/limits.js.map +1 -0
- package/dist/query-validation.d.ts +83 -0
- package/dist/query-validation.d.ts.map +1 -0
- package/dist/query-validation.js +228 -0
- package/dist/query-validation.js.map +1 -0
- package/dist/record-changes.d.ts +89 -0
- package/dist/record-changes.d.ts.map +1 -0
- package/dist/record-changes.js +201 -0
- package/dist/record-changes.js.map +1 -0
- package/dist/record-id.d.ts +70 -0
- package/dist/record-id.d.ts.map +1 -0
- package/dist/record-id.js +127 -0
- package/dist/record-id.js.map +1 -0
- package/dist/scoped-stack.d.ts +468 -0
- package/dist/scoped-stack.d.ts.map +1 -0
- package/dist/scoped-stack.js +1173 -0
- package/dist/scoped-stack.js.map +1 -0
- package/dist/stack-reads.d.ts +38 -0
- package/dist/stack-reads.d.ts.map +1 -0
- package/dist/stack-reads.js +79 -0
- package/dist/stack-reads.js.map +1 -0
- package/dist/stack.d.ts +37 -643
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +102 -2254
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +3 -1
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +16 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/wire-entry.d.ts +3 -2
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +6 -2
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-record.js +1 -1
- package/dist/wire-record.js.map +1 -1
- package/dist/wire-request.d.ts +14 -0
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +19 -2
- package/dist/wire-request.js.map +1 -1
- package/package.json +1 -1
package/dist/stack.js
CHANGED
|
@@ -13,632 +13,26 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Apps should never talk to a StackAdapter directly.
|
|
15
15
|
*/
|
|
16
|
-
import { generateId, generateIdForTimestamp
|
|
16
|
+
import { generateId, generateIdForTimestamp } from './id.js';
|
|
17
17
|
import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
|
|
18
|
-
import {
|
|
18
|
+
import { validateContent, validateContentKeys, validatePatchValues, validateReservedKeys, validateSchemaFieldNames, validateSchemaReservedNames, validateSchemaShape, } from './validate.js';
|
|
19
19
|
import { applyMergePatch } from './merge.js';
|
|
20
|
-
import {
|
|
20
|
+
import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './access.js';
|
|
21
21
|
import { compareRecordedAttachments } from './attachment-download.js';
|
|
22
|
-
import { ChangeEmitter, RelayDelivery,
|
|
23
|
-
import { SYSTEM_TYPES
|
|
22
|
+
import { ChangeEmitter, RelayDelivery, buildEmission, assertSinceUsable } from './changes.js';
|
|
23
|
+
import { SYSTEM_TYPES } from './types.js';
|
|
24
|
+
import { StackClosedError, StackConflictError, StackMigrationError, StackNotFoundError, StackPermissionError, StackQueryError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
|
|
25
|
+
import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
|
|
26
|
+
import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, matchesGrantTarget, validateGrantTarget, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
|
|
27
|
+
import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
|
|
28
|
+
import { assertAttachmentSize, assertContentSize } from './limits.js';
|
|
29
|
+
import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
|
|
30
|
+
import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
|
|
31
|
+
import { associationEqual, assertNonEmptyChangeSet, changeSetOps, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
|
|
32
|
+
import { ScopedStack } from './scoped-stack.js';
|
|
24
33
|
// -------------------------------------------------------
|
|
25
34
|
// Supporting types
|
|
26
35
|
// -------------------------------------------------------
|
|
27
|
-
/** Sentinel: filter.baseId resolved to zero matching types. */
|
|
28
|
-
const EMPTY_FAMILY = Symbol('empty-family');
|
|
29
|
-
/**
|
|
30
|
-
* Valid GrantAction values, for runtime validation in Stack.grant().
|
|
31
|
-
* Built from GRANT_ACTIONS (types.ts), the source of truth GrantAction is
|
|
32
|
-
* itself derived from — so this can't drift from the type.
|
|
33
|
-
*/
|
|
34
|
-
const GRANT_ACTION_SET = new Set(GRANT_ACTIONS);
|
|
35
|
-
/**
|
|
36
|
-
* The read actions that make a mutate action coherent, scope for scope: a
|
|
37
|
-
* `-any` verb needs read over the same reach it can mutate, a `-own` verb
|
|
38
|
-
* needs read over its author's own. A grant carrying neither conveys the
|
|
39
|
-
* verb to nobody. `create` has no companion — writing a Record you then
|
|
40
|
-
* cannot read is the drop-box, and it discloses nothing.
|
|
41
|
-
* See docs/spec/access-control.md § Write implies read.
|
|
42
|
-
*/
|
|
43
|
-
const READ_COMPANIONS = new Map([
|
|
44
|
-
['update-own', ['read-own', 'read-any']],
|
|
45
|
-
['delete-own', ['read-own', 'read-any']],
|
|
46
|
-
['update-any', ['read-any']],
|
|
47
|
-
['delete-any', ['read-any']],
|
|
48
|
-
]);
|
|
49
|
-
/**
|
|
50
|
-
* Whether one grant's action list conveys `action`. The companion has to
|
|
51
|
-
* sit in the same `_grant` Record, not merely somewhere in the grantee's
|
|
52
|
-
* set: a grant is revoked whole, so a rule satisfied across two records
|
|
53
|
-
* would let revoking the read one leave a mutate-without-read grant
|
|
54
|
-
* standing — the configuration this rule exists to refuse, arrived at
|
|
55
|
-
* without anyone writing it.
|
|
56
|
-
*/
|
|
57
|
-
function grantConveys(actions, action) {
|
|
58
|
-
if (!actions.includes(action))
|
|
59
|
-
return false;
|
|
60
|
-
const companions = READ_COMPANIONS.get(action);
|
|
61
|
-
return !companions || companions.some((c) => actions.includes(c));
|
|
62
|
-
}
|
|
63
|
-
/** Direct (non-roster) match between a stored _grant's content and a GrantTarget. */
|
|
64
|
-
function matchesGrantTarget(content, target) {
|
|
65
|
-
if (target !== null && typeof target === 'object') {
|
|
66
|
-
// Guarded so an absent groupId can't match the absent granteeGroupId on
|
|
67
|
-
// every entity-targeted and default grant — `undefined === undefined`
|
|
68
|
-
// would otherwise sweep them all into a revoke aimed at one group.
|
|
69
|
-
if (!target.groupId)
|
|
70
|
-
return false;
|
|
71
|
-
return content.granteeGroupId === target.groupId;
|
|
72
|
-
}
|
|
73
|
-
if (target === null) {
|
|
74
|
-
return !content.granteeEntityId && !content.granteeGroupId;
|
|
75
|
-
}
|
|
76
|
-
return content.granteeEntityId === target;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Reject a grant target that names nobody. An empty groupId or entityId is
|
|
80
|
-
* falsy, so a stored record carrying one reads as a *default* grant — every
|
|
81
|
-
* authenticated entity — instead of the target the caller meant. `null` is
|
|
82
|
-
* the only way to say "default".
|
|
83
|
-
*/
|
|
84
|
-
function validateGrantTarget(target) {
|
|
85
|
-
if (target === null)
|
|
86
|
-
return;
|
|
87
|
-
if (typeof target === 'string') {
|
|
88
|
-
if (target.length === 0) {
|
|
89
|
-
throw new StackQueryError('A grant target entityId cannot be empty. Pass null for a default grant.');
|
|
90
|
-
}
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (typeof target.groupId !== 'string' || target.groupId.length === 0) {
|
|
94
|
-
throw new StackQueryError('A group grant target requires a non-empty groupId.');
|
|
95
|
-
}
|
|
96
|
-
// Not a format check: granteeGroupId is a reference to an existing
|
|
97
|
-
// Record, like parentId or an association's recordId, and none of those
|
|
98
|
-
// are parsed either. One that resolves to nothing simply denies.
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Whether a stored _grant covers `grantee`: a direct DID match, roster
|
|
102
|
-
* membership when `allowGroup`, or a default when `allowDefault`. Presence
|
|
103
|
-
* decides the tier, never truthiness — an empty grantee field names nobody.
|
|
104
|
-
* Module-level so the access checks and listGrants() cannot drift apart.
|
|
105
|
-
* See docs/spec/access-control.md § Type-level grants.
|
|
106
|
-
*/
|
|
107
|
-
async function grantCoversGrantee(c, grantee, opts) {
|
|
108
|
-
const namesEntity = c.granteeEntityId !== undefined;
|
|
109
|
-
const namesGroup = c.granteeGroupId !== undefined;
|
|
110
|
-
if (!namesEntity && !namesGroup)
|
|
111
|
-
return opts.allowDefault;
|
|
112
|
-
if (namesEntity && c.granteeEntityId !== grantee)
|
|
113
|
-
return false;
|
|
114
|
-
if (namesGroup) {
|
|
115
|
-
if (!opts.allowGroup)
|
|
116
|
-
return false;
|
|
117
|
-
if (!c.granteeGroupId)
|
|
118
|
-
return false;
|
|
119
|
-
const role = await resolveGroupRoleMemoized(c.granteeGroupId, grantee, opts.groupRoles, opts.resolveRecord);
|
|
120
|
-
if (role === null)
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
return true;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* An entity's role on a `_group` roster, memoized in the caller's
|
|
127
|
-
* `groupRoles` map. That map is built per operation and threaded alongside
|
|
128
|
-
* `prefetchedGrants`, so no resolved role outlives the operation that
|
|
129
|
-
* resolved it — removal from a group must never go stale.
|
|
130
|
-
* See docs/spec/access-control.md § Type-level grants.
|
|
131
|
-
*/
|
|
132
|
-
async function resolveGroupRoleMemoized(groupId, entityId, groupRoles, resolveRecord) {
|
|
133
|
-
const key = `${groupId}:${entityId}`;
|
|
134
|
-
const cached = groupRoles.get(key);
|
|
135
|
-
if (cached !== undefined)
|
|
136
|
-
return cached;
|
|
137
|
-
const group = await resolveRecord(groupId);
|
|
138
|
-
// Only a real `_group` Record carries a roster. Without the family check
|
|
139
|
-
// any Record's relationship associations would serve as one, and a group
|
|
140
|
-
// migrated out of the family would keep resolving after it had stopped
|
|
141
|
-
// being a group.
|
|
142
|
-
const role = group && baseIdOf(group.typeId) === SYSTEM_TYPES.GROUP
|
|
143
|
-
? groupRoleFromAssociations(group.associations, entityId)
|
|
144
|
-
: null;
|
|
145
|
-
groupRoles.set(key, role);
|
|
146
|
-
return role;
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* System type families grant() refuses to target: a grant on any of them
|
|
150
|
-
* would let the grantee mint their own grants, touch stack config, or
|
|
151
|
-
* register an app card claiming a DID that isn't theirs — the last of
|
|
152
|
-
* which is what verified app attribution rests on. See
|
|
153
|
-
* docs/spec/access-control.md § Type-level grants.
|
|
154
|
-
*/
|
|
155
|
-
const UNGRANTABLE_SYSTEM_TYPES = new Set([
|
|
156
|
-
SYSTEM_TYPES.GRANT,
|
|
157
|
-
SYSTEM_TYPES.CONFIG,
|
|
158
|
-
SYSTEM_TYPES.APP,
|
|
159
|
-
]);
|
|
160
|
-
/**
|
|
161
|
-
* Content fields that are lookup keys rather than display values: a card
|
|
162
|
-
* claims one, and something later resolves through it. Every one of them is
|
|
163
|
-
* immutable once set. See docs/spec/identity.md § DID bindings.
|
|
164
|
-
*/
|
|
165
|
-
const BINDING_FIELDS = new Map([
|
|
166
|
-
[SYSTEM_TYPES.APP, ['did', 'appId']],
|
|
167
|
-
[SYSTEM_TYPES.ENTITY, ['did']],
|
|
168
|
-
]);
|
|
169
|
-
/**
|
|
170
|
-
* The subset that is additionally unique per stack: the fields something
|
|
171
|
-
* resolves *by*. A Record's `principalId` finds its card by `_app.did` and
|
|
172
|
-
* its `entityId` by `_entity.did`, so a second card claiming either leaves
|
|
173
|
-
* that lookup without a single answer — and ambiguity is all an
|
|
174
|
-
* impersonating card needs.
|
|
175
|
-
*
|
|
176
|
-
* `_app.appId` is deliberately absent. Nothing resolves a card by it — the
|
|
177
|
-
* cross-check reaches the card by `did` and only compares `appId` — so
|
|
178
|
-
* uniqueness would buy no disambiguation, while forbidding the second card
|
|
179
|
-
* key rotation is supposed to produce: `appId` is required, so a
|
|
180
|
-
* replacement card for the same software necessarily repeats it. Moving one
|
|
181
|
-
* card onto another's `appId` is what immutability already refuses.
|
|
182
|
-
* See docs/spec/identity.md § DID bindings.
|
|
183
|
-
*/
|
|
184
|
-
const UNIQUE_BINDING_FIELDS = new Map([
|
|
185
|
-
[SYSTEM_TYPES.APP, ['did']],
|
|
186
|
-
[SYSTEM_TYPES.ENTITY, ['did']],
|
|
187
|
-
]);
|
|
188
|
-
const bindingFieldsOf = (family) => BINDING_FIELDS.get(family) ?? [];
|
|
189
|
-
const uniqueBindingFieldsOf = (family) => UNIQUE_BINDING_FIELDS.get(family) ?? [];
|
|
190
|
-
/**
|
|
191
|
-
* Root of the Stack error taxonomy. A single `instanceof StackError` answers
|
|
192
|
-
* "is this a Stack-domain error or a bug?" — the question a server's error
|
|
193
|
-
* middleware asks before serializing a wire body, and one a nine-arm
|
|
194
|
-
* instanceof ladder answers only by exhaustion. Every subclass carries its
|
|
195
|
-
* discriminator as an instance `code`, so serialization is a lookup rather
|
|
196
|
-
* than a chain of class tests.
|
|
197
|
-
*
|
|
198
|
-
* Membership implies a wire mapping: every code has an entry in
|
|
199
|
-
* WIRE_ERROR_STATUS. Errors with no wire representation (IdGenerationError,
|
|
200
|
-
* InvalidDidError) deliberately stay outside this hierarchy.
|
|
201
|
-
*
|
|
202
|
-
* Subclassing adds no hierarchy beyond this root — notably
|
|
203
|
-
* StackVersionConflictError is a sibling of StackConflictError, not a
|
|
204
|
-
* subtype. See docs/spec/wire-format.md § Error responses.
|
|
205
|
-
*/
|
|
206
|
-
export class StackError extends Error {
|
|
207
|
-
}
|
|
208
|
-
export class StackValidationError extends StackError {
|
|
209
|
-
errors;
|
|
210
|
-
static code = 'validation';
|
|
211
|
-
code = StackValidationError.code;
|
|
212
|
-
constructor(errors) {
|
|
213
|
-
super(`Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'));
|
|
214
|
-
this.errors = errors;
|
|
215
|
-
this.name = 'StackValidationError';
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
export class StackMigrationError extends StackError {
|
|
219
|
-
static code = 'migration';
|
|
220
|
-
code = StackMigrationError.code;
|
|
221
|
-
constructor(message) {
|
|
222
|
-
super(message);
|
|
223
|
-
this.name = 'StackMigrationError';
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
/** Thrown by ScopedStack when a requester lacks permission for the operation. */
|
|
227
|
-
export class StackPermissionError extends StackError {
|
|
228
|
-
static code = 'permission';
|
|
229
|
-
code = StackPermissionError.code;
|
|
230
|
-
constructor(message = 'Permission denied') {
|
|
231
|
-
super(message);
|
|
232
|
-
this.name = 'StackPermissionError';
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
/** Thrown when a record (or specific version) does not exist. */
|
|
236
|
-
export class StackNotFoundError extends StackError {
|
|
237
|
-
static code = 'not_found';
|
|
238
|
-
code = StackNotFoundError.code;
|
|
239
|
-
constructor(message) {
|
|
240
|
-
super(message);
|
|
241
|
-
this.name = 'StackNotFoundError';
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */
|
|
245
|
-
export class StackConflictError extends StackError {
|
|
246
|
-
static code = 'conflict';
|
|
247
|
-
code = StackConflictError.code;
|
|
248
|
-
constructor(message) {
|
|
249
|
-
super(message);
|
|
250
|
-
this.name = 'StackConflictError';
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Thrown when an `ifVersion` precondition doesn't match a record's current
|
|
255
|
-
* version. Deliberately not a StackConflictError subtype — the two have
|
|
256
|
-
* different recovery stories and HTTP statuses (409 vs. 412). See
|
|
257
|
-
* docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
|
|
258
|
-
*/
|
|
259
|
-
export class StackVersionConflictError extends StackError {
|
|
260
|
-
recordId;
|
|
261
|
-
expectedVersion;
|
|
262
|
-
actualVersion;
|
|
263
|
-
static code = 'version_conflict';
|
|
264
|
-
code = StackVersionConflictError.code;
|
|
265
|
-
constructor(message, recordId, expectedVersion, actualVersion) {
|
|
266
|
-
super(message);
|
|
267
|
-
this.recordId = recordId;
|
|
268
|
-
this.expectedVersion = expectedVersion;
|
|
269
|
-
this.actualVersion = actualVersion;
|
|
270
|
-
this.name = 'StackVersionConflictError';
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Thrown when a request is structurally malformed — not a content-validation
|
|
275
|
-
* failure, but input the adapter/server can't even interpret (e.g. an
|
|
276
|
-
* undecodable pagination cursor, a malformed TypeId, search text the engine
|
|
277
|
-
* cannot parse, or a typeId no definition exists for). Distinct from
|
|
278
|
-
* StackValidationError, which means the request was well-formed but content
|
|
279
|
-
* failed schema validation.
|
|
280
|
-
*
|
|
281
|
-
* Naming something absent belongs here rather than under `not_found`: a
|
|
282
|
-
* request whose *type* is undefined never addressed a record, so answering
|
|
283
|
-
* 404 would say a record was missing when none was asked for.
|
|
284
|
-
*/
|
|
285
|
-
export class StackQueryError extends StackError {
|
|
286
|
-
static code = 'bad_request';
|
|
287
|
-
code = StackQueryError.code;
|
|
288
|
-
constructor(message) {
|
|
289
|
-
super(message);
|
|
290
|
-
this.name = 'StackQueryError';
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* Fail loud rather than silently widen: a filter the adapter can't honor
|
|
295
|
-
* would otherwise be dropped, returning an unfiltered superset presented
|
|
296
|
-
* as the filtered result. Shared by Stack.query() and
|
|
297
|
-
* APIAdapter.queryRecords(). See docs/spec/data-model.md
|
|
298
|
-
* § Capability-gated filters.
|
|
299
|
-
*/
|
|
300
|
-
export function assertQueryCapabilities(filter, capabilities) {
|
|
301
|
-
const { content: reach, contentPresent, search } = capabilities.filter;
|
|
302
|
-
if (filter?.search && !search) {
|
|
303
|
-
throw new StackQueryError('Query uses filter.search, but this adapter does not declare the filter.search capability.');
|
|
304
|
-
}
|
|
305
|
-
const present = filter?.contentPresent?.length ? filter.contentPresent : undefined;
|
|
306
|
-
if (!filter?.content && !present)
|
|
307
|
-
return;
|
|
308
|
-
if (reach === 'none') {
|
|
309
|
-
throw new StackQueryError(`Query uses ${present && !filter?.content ? 'filter.contentPresent' : 'filter.content'}, ` +
|
|
310
|
-
'but this adapter declares filter.content: "none".');
|
|
311
|
-
}
|
|
312
|
-
if (present && !contentPresent) {
|
|
313
|
-
throw new StackQueryError('Query uses filter.contentPresent, but this adapter does not declare the ' +
|
|
314
|
-
'filter.contentPresent capability.');
|
|
315
|
-
}
|
|
316
|
-
for (const key of [...Object.keys(filter?.content ?? {}), ...(present ?? [])]) {
|
|
317
|
-
if (parseContentFilterKey(key).length > 1 && reach !== 'path') {
|
|
318
|
-
throw new StackQueryError(`Query uses the nested content path "${key}", but this adapter declares ` +
|
|
319
|
-
`filter.content: "${reach}".`);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Whether a single-segment content filter can be pushed down to the
|
|
325
|
-
* adapter. Callers that read a content field pair this with an in-memory
|
|
326
|
-
* predicate over the wider result, so the query stays correct against an
|
|
327
|
-
* adapter that reaches no content at all — the rung comparison lives here
|
|
328
|
-
* rather than at each of them.
|
|
329
|
-
*/
|
|
330
|
-
function filtersContent(features) {
|
|
331
|
-
return features.filter.content !== 'none';
|
|
332
|
-
}
|
|
333
|
-
/** The longest path both SQLite engines can execute — see the spec link below. */
|
|
334
|
-
const MAX_CONTENT_PATH_SEGMENTS = 32;
|
|
335
|
-
/**
|
|
336
|
-
* Split a content filter key into path segments. Write-time validation
|
|
337
|
-
* keeps stored field names free of the path metacharacters, but a filter
|
|
338
|
-
* arrives from a request body and has made no such promise, so the same
|
|
339
|
-
* rule is enforced here. See docs/spec/data-model.md § Content field names.
|
|
340
|
-
*/
|
|
341
|
-
export function parseContentFilterKey(key) {
|
|
342
|
-
const segments = key.split('.');
|
|
343
|
-
// A SQLite adapter spends two json_each joins per segment against a
|
|
344
|
-
// 64-table join limit, so a path past the cap is one the engine could
|
|
345
|
-
// not execute. See docs/spec/data-model.md § Nested content paths.
|
|
346
|
-
if (segments.length > MAX_CONTENT_PATH_SEGMENTS) {
|
|
347
|
-
throw new StackQueryError(`Invalid content filter path "${key}": at most ${MAX_CONTENT_PATH_SEGMENTS} segments.`);
|
|
348
|
-
}
|
|
349
|
-
for (const segment of segments) {
|
|
350
|
-
if (segment === '') {
|
|
351
|
-
throw new StackQueryError(`Invalid content filter path "${key}": a path segment cannot be empty.`);
|
|
352
|
-
}
|
|
353
|
-
if (SEGMENT_METACHARACTER_RE.test(segment)) {
|
|
354
|
-
throw new StackQueryError(`Invalid content filter path "${key}": a segment cannot contain any of ` +
|
|
355
|
-
`${CONTENT_SEGMENT_METACHARACTERS.join(' ')}.`);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return segments;
|
|
359
|
-
}
|
|
360
|
-
/** The only sort fields any adapter maps; anything else is a caller error. */
|
|
361
|
-
const VALID_SORT_FIELDS = new Set(['createdAt', 'updatedAt', 'version']);
|
|
362
|
-
/** The only two sort directions; see assertValidSort. */
|
|
363
|
-
const VALID_SORT_DIRECTIONS = new Set(['asc', 'desc']);
|
|
364
|
-
/**
|
|
365
|
-
* Reject a sort whose field or direction is outside the closed set the
|
|
366
|
-
* types promise. `QuerySort` is typed `'asc' | 'desc'`, but a type is not a
|
|
367
|
-
* runtime guard: a server mapping `?direction=` onto a query, or a
|
|
368
|
-
* delegated app calling query(), supplies a raw string. A SQLite record
|
|
369
|
-
* adapter interpolates the direction straight into `ORDER BY`, so an
|
|
370
|
-
* unvalidated value there is a SQL-injection sink reachable from every
|
|
371
|
-
* untrusted caller. Validating in the invariant layer — the same reason
|
|
372
|
-
* emission and _config protection live here — means no adapter can forget
|
|
373
|
-
* it. See docs/spec/data-model.md § Sorting and pagination.
|
|
374
|
-
*/
|
|
375
|
-
export function assertValidSort(sort) {
|
|
376
|
-
if (!sort)
|
|
377
|
-
return;
|
|
378
|
-
if (sort.field !== undefined && sort.contentField !== undefined) {
|
|
379
|
-
throw new StackQueryError('A sort names either a native field or a content field, never both.');
|
|
380
|
-
}
|
|
381
|
-
if (sort.field !== undefined && !VALID_SORT_FIELDS.has(sort.field)) {
|
|
382
|
-
throw new StackQueryError(`Invalid sort field "${sort.field}": expected one of createdAt, updatedAt, version.`);
|
|
383
|
-
}
|
|
384
|
-
if (sort.contentField !== undefined) {
|
|
385
|
-
// Parsed by the same rule a filter key is, so a name a filter could
|
|
386
|
-
// never address is not one a sort can either — then held to one
|
|
387
|
-
// segment, because a value inside an array or object has no single
|
|
388
|
-
// position to order its record by (docs/spec/data-model.md
|
|
389
|
-
// § Sorting by a content field).
|
|
390
|
-
if (parseContentFilterKey(sort.contentField).length > 1) {
|
|
391
|
-
throw new StackQueryError(`Invalid sort content field "${sort.contentField}": sorting reaches top-level fields only.`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
if (sort.direction !== undefined && !VALID_SORT_DIRECTIONS.has(sort.direction)) {
|
|
395
|
-
throw new StackQueryError(`Invalid sort direction "${sort.direction}": expected "asc" or "desc".`);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Fail loud rather than silently reorder: an adapter that can't honor the
|
|
400
|
-
* requested sort would otherwise answer in some other order, which a
|
|
401
|
-
* caller paging a bounded window has no way to notice. The companion to
|
|
402
|
-
* assertQueryCapabilities(), split from it because a sort is not a filter
|
|
403
|
-
* — see docs/spec/data-model.md § Capability-gated filters.
|
|
404
|
-
*/
|
|
405
|
-
export function assertSortCapability(sort, capabilities) {
|
|
406
|
-
if (!sort)
|
|
407
|
-
return;
|
|
408
|
-
if (sort.contentField !== undefined) {
|
|
409
|
-
if (!capabilities.sort.contentField) {
|
|
410
|
-
throw new StackQueryError('Query uses sort.contentField, but this adapter does not declare the sort.contentField ' +
|
|
411
|
-
'capability.');
|
|
412
|
-
}
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const field = sort.field ?? 'createdAt';
|
|
416
|
-
if (!capabilities.sort.fields.includes(field)) {
|
|
417
|
-
throw new StackQueryError(`Query sorts by "${field}", which this adapter does not declare in sort.fields.`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
/** The identifier spaces a relationship target may name. */
|
|
421
|
-
const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
|
|
422
|
-
/**
|
|
423
|
-
* Collect what makes a relationship target malformed. Absence is
|
|
424
|
-
* meaningful on `stackUrl` and an external `id` — this stack, and the
|
|
425
|
-
* whole namespace — so every part that names something must be non-empty:
|
|
426
|
-
* an empty string stores and matches as though it were absent.
|
|
427
|
-
* See docs/spec/data-model.md § Relationship targets.
|
|
428
|
-
*/
|
|
429
|
-
function targetErrors(target, path, opts = {}) {
|
|
430
|
-
const fail = (message) => [{ path, message }];
|
|
431
|
-
if (!target || typeof target !== 'object')
|
|
432
|
-
return fail('A relationship target must be an object.');
|
|
433
|
-
if (!TARGET_SCOPES.has(target.scope)) {
|
|
434
|
-
return fail(`Unknown relationship target scope "${target.scope}": expected "record", "entity" or "external".`);
|
|
435
|
-
}
|
|
436
|
-
if (target.scope === 'record') {
|
|
437
|
-
if (!target.recordId)
|
|
438
|
-
return fail('A record target requires a non-empty recordId.');
|
|
439
|
-
if (target.stackUrl !== undefined && !target.stackUrl) {
|
|
440
|
-
return fail("A record target's stackUrl must be non-empty; omit it to name this stack.");
|
|
441
|
-
}
|
|
442
|
-
return [];
|
|
443
|
-
}
|
|
444
|
-
if (target.scope === 'entity') {
|
|
445
|
-
return target.entityId ? [] : fail('An entity target requires a non-empty entityId.');
|
|
446
|
-
}
|
|
447
|
-
if (!target.ns)
|
|
448
|
-
return fail('An external target requires a non-empty ns.');
|
|
449
|
-
if (target.id === undefined) {
|
|
450
|
-
return opts.externalIdOptional ? [] : fail('An external target requires an id.');
|
|
451
|
-
}
|
|
452
|
-
return target.id ? [] : fail("An external target's id must be non-empty when present.");
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Reject a relationship target outside the closed set the types promise.
|
|
456
|
-
* A discriminated union is not a runtime guard — a server mapping a
|
|
457
|
-
* request body onto an association supplies raw JSON — and an
|
|
458
|
-
* unrecognized scope would otherwise be stored under the one arm that
|
|
459
|
-
* names a Record in this stack. See docs/spec/data-model.md
|
|
460
|
-
* § Relationship targets.
|
|
461
|
-
*/
|
|
462
|
-
function validateAssociation(association, path = 'association') {
|
|
463
|
-
if (association?.kind !== 'relationship')
|
|
464
|
-
return [];
|
|
465
|
-
return targetErrors(association.target, `${path}.target`);
|
|
466
|
-
}
|
|
467
|
-
/** validateAssociation() over a create's `associations` array. */
|
|
468
|
-
function validateAssociations(associations, path = 'associations') {
|
|
469
|
-
return (associations ?? []).flatMap((a, i) => validateAssociation(a, `${path}[${i}]`));
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* Reject a relationship filter that names neither a label nor a target,
|
|
473
|
-
* or whose target is malformed. `RelatedToFilter` promises one half is
|
|
474
|
-
* always present; without the runtime check a filter decoded from a
|
|
475
|
-
* request could arrive empty and match every record carrying any
|
|
476
|
-
* relationship. See docs/spec/data-model.md § Filter.
|
|
477
|
-
*/
|
|
478
|
-
export function assertValidRelatedTo(relatedTo) {
|
|
479
|
-
if (!relatedTo)
|
|
480
|
-
return;
|
|
481
|
-
if (relatedTo.label === undefined && relatedTo.target === undefined) {
|
|
482
|
-
throw new StackQueryError('filter.relatedTo must name a label, a target, or both — "any relationship at all" is not a filter.');
|
|
483
|
-
}
|
|
484
|
-
if (relatedTo.target === undefined)
|
|
485
|
-
return;
|
|
486
|
-
const errors = targetErrors(relatedTo.target, 'filter.relatedTo.target', {
|
|
487
|
-
externalIdOptional: true,
|
|
488
|
-
});
|
|
489
|
-
if (errors.length > 0)
|
|
490
|
-
throw new StackQueryError(errors[0].message);
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* Thrown when an attachment upload exceeds the adapter's declared
|
|
494
|
-
* `limits.attachmentBytes` ceiling — checked client-side before any bytes
|
|
495
|
-
* are sent; a server still enforces 413 authoritatively regardless. See
|
|
496
|
-
* docs/spec/wire-format.md § Attachments.
|
|
497
|
-
*/
|
|
498
|
-
export class StackPayloadTooLargeError extends StackError {
|
|
499
|
-
static code = 'payload_too_large';
|
|
500
|
-
code = StackPayloadTooLargeError.code;
|
|
501
|
-
constructor(message) {
|
|
502
|
-
super(message);
|
|
503
|
-
this.name = 'StackPayloadTooLargeError';
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
/**
|
|
507
|
-
* Thrown when a server abandons an operation for taking too long — in
|
|
508
|
-
* practice a full-text search, the one query whose cost the sanitizers
|
|
509
|
-
* bound the *grammar* of but not the execution of (see
|
|
510
|
-
* docs/spec/data-model.md § Capability-gated filters).
|
|
511
|
-
*
|
|
512
|
-
* Never produced in-process: both SQLite engines run synchronously, so
|
|
513
|
-
* there is nothing to interrupt from inside the call. It exists so a
|
|
514
|
-
* server that bounds query time has a class to serialize, and so the app
|
|
515
|
-
* catching it can tell "too expensive, narrow it and retry" from
|
|
516
|
-
* StackQueryError's "malformed, don't bother retrying" — the distinction
|
|
517
|
-
* that would be lost if a timeout reused `bad_request`.
|
|
518
|
-
*/
|
|
519
|
-
export class StackTimeoutError extends StackError {
|
|
520
|
-
static code = 'timeout';
|
|
521
|
-
code = StackTimeoutError.code;
|
|
522
|
-
constructor(message) {
|
|
523
|
-
super(message);
|
|
524
|
-
this.name = 'StackTimeoutError';
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
/** Shared by Stack.putAttachment() and ScopedStack.putAttachment(). */
|
|
528
|
-
function assertAttachmentSize(byteLength, attachmentBytes) {
|
|
529
|
-
if (attachmentBytes !== null && byteLength > attachmentBytes) {
|
|
530
|
-
throw new StackPayloadTooLargeError(`Attachment (${byteLength} bytes) exceeds the ${attachmentBytes}-byte limit.`);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* The content half of the same pre-check, on Stack.create() and
|
|
535
|
-
* Stack.mutate(). Local adapters declare `limits.contentBytes: null` and skip
|
|
536
|
-
* the serialization entirely; only a server declares a ceiling, and its
|
|
537
|
-
* own request-size limit stays authoritative — this just spares an app the
|
|
538
|
-
* round trip and gives it a typed failure instead of a 413 it has to
|
|
539
|
-
* interpret. See docs/spec/wire-format.md § Request size limits.
|
|
540
|
-
*/
|
|
541
|
-
function assertContentSize(content, contentBytes, what) {
|
|
542
|
-
if (contentBytes === null)
|
|
543
|
-
return;
|
|
544
|
-
const byteLength = new TextEncoder().encode(JSON.stringify(content)).length;
|
|
545
|
-
if (byteLength > contentBytes) {
|
|
546
|
-
throw new StackPayloadTooLargeError(`${what} (${byteLength} bytes) exceeds the ${contentBytes}-byte limit.`);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* Thrown by defineType() when redefining an existing typeId with a schema
|
|
551
|
-
* change beyond additive evolution. The remedy is a new version, never an
|
|
552
|
-
* in-place redefinition. See docs/spec/data-model.md § Schema drift
|
|
553
|
-
* detection.
|
|
554
|
-
*/
|
|
555
|
-
export class StackSchemaDriftError extends StackError {
|
|
556
|
-
typeId;
|
|
557
|
-
violations;
|
|
558
|
-
static code = 'schema_drift';
|
|
559
|
-
code = StackSchemaDriftError.code;
|
|
560
|
-
constructor(typeId, violations) {
|
|
561
|
-
super(`Schema drift detected for type "${typeId}": the stored schema and the new definition ` +
|
|
562
|
-
`differ beyond additive evolution (new optional fields only). Bump the version instead ` +
|
|
563
|
-
`of redefining "${typeId}" in place — e.g. defineType(\`${baseIdOf(typeId)}@${(parseTypeId(typeId)?.version ?? 0) + 1}\`, ...) plus registerMigration().\n` +
|
|
564
|
-
violations.map((v) => ` ${v.path || '(root)'}: ${v.message}`).join('\n'));
|
|
565
|
-
this.typeId = typeId;
|
|
566
|
-
this.violations = violations;
|
|
567
|
-
this.name = 'StackSchemaDriftError';
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
/**
|
|
571
|
-
* Thrown when a Stack or ScopedStack is used after close(). Deliberately
|
|
572
|
-
* outside the StackError taxonomy, alongside IdGenerationError and
|
|
573
|
-
* InvalidDidError: a caller holding a closed client is a local programming
|
|
574
|
-
* error with no wire representation — no server ever responds with it.
|
|
575
|
-
* See docs/spec/adapters.md § Lifecycle.
|
|
576
|
-
*/
|
|
577
|
-
export class StackClosedError extends Error {
|
|
578
|
-
constructor(message = 'This Stack has been closed.') {
|
|
579
|
-
super(message);
|
|
580
|
-
this.name = 'StackClosedError';
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Thrown when a scoped view is asked to observe a stack whose adapter
|
|
585
|
-
* relays a remote feed. Outside the StackError taxonomy for the same
|
|
586
|
-
* reason StackClosedError is: it reports a topology the caller assembled,
|
|
587
|
-
* not a state a request can be in.
|
|
588
|
-
*
|
|
589
|
-
* A relayed frame is scoped by the authority that opened the feed, and a
|
|
590
|
-
* narrower scope cannot re-derive that decision — a purge leaves no record
|
|
591
|
-
* to check `canRead` against. Delivering anyway would break the promise
|
|
592
|
-
* that a subscriber never sees what it may not read; delivering only local
|
|
593
|
-
* writes would silently drop every change made elsewhere, which is the
|
|
594
|
-
* failure that looks fine in testing. So it refuses.
|
|
595
|
-
* See docs/spec/events.md § Permission scoping.
|
|
596
|
-
*/
|
|
597
|
-
export class StackRelayScopeError extends Error {
|
|
598
|
-
constructor(message) {
|
|
599
|
-
super(message);
|
|
600
|
-
this.name = 'StackRelayScopeError';
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
/**
|
|
604
|
-
* A resume cursor is opaque, but not arbitrary: it travels in an SSE `id:`
|
|
605
|
-
* field, so a value spanning a line would truncate the frame carrying it.
|
|
606
|
-
* Same charset and same reason as the auth nonce — see
|
|
607
|
-
* docs/spec/change-feed.md § Frames.
|
|
608
|
-
*/
|
|
609
|
-
const SEQ_FORMAT = /^[A-Za-z0-9_-]+$/;
|
|
610
|
-
/**
|
|
611
|
-
* `since` only means something where a relay exists: a stack with no
|
|
612
|
-
* third party whose writes could have been missed has no cursor it could
|
|
613
|
-
* ever have minted. Silently starting from the present would let the
|
|
614
|
-
* caller believe it resumed when it did not, so a stack that cannot honor
|
|
615
|
-
* `since` refuses it rather than ignoring it.
|
|
616
|
-
*
|
|
617
|
-
* Its shape is checked here rather than left to the adapter, so that a
|
|
618
|
-
* malformed cursor is the same error whoever is underneath — the posture
|
|
619
|
-
* query() already takes with a filter no adapter declared. The value stays
|
|
620
|
-
* opaque: this asks whether it is framable, never what it means. See
|
|
621
|
-
* docs/spec/events.md § Subscribing.
|
|
622
|
-
*/
|
|
623
|
-
function assertSinceUsable(since, relaysChanges) {
|
|
624
|
-
if (since === undefined)
|
|
625
|
-
return;
|
|
626
|
-
if (!relaysChanges) {
|
|
627
|
-
throw new StackQueryError('subscribe() was passed `since`, but this stack relays no changes from elsewhere and so ' +
|
|
628
|
-
'has no cursor it could ever have minted. Omit `since` — or, for a stack that relays ' +
|
|
629
|
-
'from a server, use the seq off a previously delivered RecordChange.');
|
|
630
|
-
}
|
|
631
|
-
if (!SEQ_FORMAT.test(since)) {
|
|
632
|
-
throw new StackQueryError(`subscribe() was passed the resume cursor "${since}", which is not a valid seq: a cursor ` +
|
|
633
|
-
'carries unreserved base64url characters only, because it travels in a frame id. Use ' +
|
|
634
|
-
'the seq off a previously delivered RecordChange, unaltered.');
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
// -------------------------------------------------------
|
|
638
|
-
// Record ID validation
|
|
639
|
-
// -------------------------------------------------------
|
|
640
|
-
const RESERVED_ID_PREFIX = '_';
|
|
641
|
-
const DEFAULT_ID_TIMESTAMP_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
642
36
|
/**
|
|
643
37
|
* Default grace period for Stack.collectAttachmentGarbage(), covering the
|
|
644
38
|
* upload-then-associate window. See docs/spec/attachments.md § Garbage
|
|
@@ -646,179 +40,24 @@ const DEFAULT_ID_TIMESTAMP_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
|
646
40
|
*/
|
|
647
41
|
const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
648
42
|
/**
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
* Throws StackQueryError, not StackValidationError: a malformed id is
|
|
655
|
-
* structurally bad input the request never gets past — it doesn't reach
|
|
656
|
-
* type-schema validation — the same reasoning that makes an undecodable
|
|
657
|
-
* pagination cursor a StackQueryError rather than a content-validation
|
|
658
|
-
* failure. See StackQueryError's doc comment.
|
|
659
|
-
*/
|
|
660
|
-
/**
|
|
661
|
-
* The same format rule applied to a `parentId` a caller names. Separate
|
|
662
|
-
* from validateRecordId() only for its message: the id under discussion is
|
|
663
|
-
* the destination, not the record being written, and a shared message would
|
|
664
|
-
* report the wrong one. See docs/spec/data-model.md § Reparenting.
|
|
665
|
-
*/
|
|
666
|
-
function validateParentId(parentId) {
|
|
667
|
-
if (parentId.startsWith(RESERVED_ID_PREFIX)) {
|
|
668
|
-
throw new StackQueryError(`Invalid parentId "${parentId}": uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
|
|
669
|
-
}
|
|
670
|
-
if (!isValidIdFormat(parentId)) {
|
|
671
|
-
throw new StackQueryError(`Invalid parentId "${parentId}": expected 12 lowercase Crockford base-32 characters.`);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
function validateRecordId(id) {
|
|
675
|
-
if (id.startsWith(RESERVED_ID_PREFIX)) {
|
|
676
|
-
throw new StackQueryError(`ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
|
|
677
|
-
}
|
|
678
|
-
if (!isValidIdFormat(id)) {
|
|
679
|
-
throw new StackQueryError(`Invalid ID "${id}": expected 12 lowercase Crockford base-32 characters.`);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
/**
|
|
683
|
-
* Validity and range check for the backdating options on unscoped
|
|
684
|
-
* Stack.create(). A Date is only as good as what the caller parsed it
|
|
685
|
-
* from, and the two failure modes both need catching here rather than
|
|
686
|
-
* downstream:
|
|
687
|
-
*
|
|
688
|
-
* - **Invalid Date** (`new Date('13/45/2020')` off a malformed import row)
|
|
689
|
-
* has a NaN getTime(), and every comparison against NaN is false — so an
|
|
690
|
-
* unchecked Invalid Date passes the updatedAt/createdAt ordering check
|
|
691
|
-
* and the id/createdAt skew check by turning them off, mints the
|
|
692
|
-
* epoch-zero ID `000000000xxx`, and persists a record whose
|
|
693
|
-
* `createdAt.toISOString()` throws RangeError in serializeRecord() —
|
|
694
|
-
* making that record, and any wire response containing it,
|
|
695
|
-
* permanently unreadable.
|
|
696
|
-
* - **Out of encodable range** — before 1970 crockford32Encode() throws a
|
|
697
|
-
* bare RangeError from deep inside the ID encoder, and past
|
|
698
|
-
* MAX_ID_TIMESTAMP the derived ID silently grows to 13 characters and
|
|
699
|
-
* fails isValidIdFormat().
|
|
700
|
-
*
|
|
701
|
-
* Checked at the door for both, as a StackValidationError naming the
|
|
702
|
-
* field. Applies whether or not an `id` is supplied: a clock field outside
|
|
703
|
-
* this range is unrepresentable regardless of where the ID came from.
|
|
704
|
-
*/
|
|
705
|
-
function validateClockField(value, path) {
|
|
706
|
-
if (value === undefined)
|
|
707
|
-
return [];
|
|
708
|
-
// Type-checked callers always pass a Date, but this option is now
|
|
709
|
-
// reachable from the wire: JSON has no Date, so a server that forwards a
|
|
710
|
-
// parsed `POST /records` body hands us the ISO *string* it deserialized.
|
|
711
|
-
// Without this the very next line is `"2020-…".getTime()` — an unhandled
|
|
712
|
-
// TypeError, a 500 where the caller should have got a 400 naming the
|
|
713
|
-
// field.
|
|
714
|
-
if (!(value instanceof Date)) {
|
|
715
|
-
return [{ path, message: `${path} must be a Date.` }];
|
|
716
|
-
}
|
|
717
|
-
const ms = value.getTime();
|
|
718
|
-
if (Number.isNaN(ms)) {
|
|
719
|
-
return [{ path, message: `${path} is not a valid Date.` }];
|
|
720
|
-
}
|
|
721
|
-
if (ms < 0 || ms > MAX_ID_TIMESTAMP) {
|
|
722
|
-
return [
|
|
723
|
-
{
|
|
724
|
-
path,
|
|
725
|
-
message: `${path} is outside the representable range ` +
|
|
726
|
-
`(1970-01-01T00:00:00.000Z…${new Date(MAX_ID_TIMESTAMP).toISOString()}).`,
|
|
727
|
-
},
|
|
728
|
-
];
|
|
729
|
-
}
|
|
730
|
-
return [];
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Timestamp-prefix plausibility check, shared by callers that compare an
|
|
734
|
-
* ID's embedded millisecond against a different reference each:
|
|
735
|
-
* ScopedStack.create() against the current time for any non-backdated
|
|
736
|
-
* create (a grantee is untrusted and could otherwise mint an ID that
|
|
737
|
-
* forges its sort position, and this is the one check standing between a
|
|
738
|
-
* delegated or grantee caller and doing so), and Stack.create() — reached
|
|
739
|
-
* directly when unscoped, or via ScopedStack.create() when the requester
|
|
740
|
-
* is the owner acting alone with an explicit `createdAt` — against that
|
|
741
|
-
* `createdAt` instead (the two must agree, not silently diverge — see
|
|
742
|
-
* docs/spec/data-model.md § Record IDs). Pass null to disable.
|
|
743
|
-
*/
|
|
744
|
-
function validateIdTimestampSkew(id, toleranceMs, referenceMs, referenceLabel) {
|
|
745
|
-
if (toleranceMs === null)
|
|
746
|
-
return;
|
|
747
|
-
const skew = Math.abs(referenceMs - idTimestamp(id));
|
|
748
|
-
if (skew > toleranceMs) {
|
|
749
|
-
throw new StackValidationError([
|
|
750
|
-
{
|
|
751
|
-
path: 'id',
|
|
752
|
-
message: `ID "${id}" timestamp disagrees with ${referenceLabel} by more than the allowed clock-skew tolerance (${toleranceMs}ms).`,
|
|
753
|
-
},
|
|
754
|
-
]);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
// -------------------------------------------------------
|
|
758
|
-
// Query helpers
|
|
759
|
-
// -------------------------------------------------------
|
|
760
|
-
/**
|
|
761
|
-
* Walks `cursor` to exhaustion for the internal call sites that need every
|
|
762
|
-
* match (grant checks, attachment cleanup) — query() itself always
|
|
763
|
-
* paginates. Throws StackQueryError past `max` rather than silently
|
|
764
|
-
* truncating a runaway scan. Not a public API.
|
|
765
|
-
*/
|
|
766
|
-
async function queryAllPages(run, query, max = QUERY_ALL_MAX) {
|
|
767
|
-
const records = [];
|
|
768
|
-
let cursor = query.cursor;
|
|
769
|
-
do {
|
|
770
|
-
const page = await run({ ...query, cursor });
|
|
771
|
-
records.push(...page.records);
|
|
772
|
-
if (records.length > max) {
|
|
773
|
-
throw new StackQueryError(`queryAllPages: exceeded max of ${max} records without exhausting the cursor`);
|
|
774
|
-
}
|
|
775
|
-
cursor = page.cursor ?? undefined;
|
|
776
|
-
} while (cursor);
|
|
777
|
-
return records;
|
|
778
|
-
}
|
|
779
|
-
/** Safety cap for queryAllPages() — generous for personal-stack scale. */
|
|
780
|
-
const QUERY_ALL_MAX = 10_000;
|
|
781
|
-
/**
|
|
782
|
-
* Cursor-walks `run(query)` looking for the first record matching
|
|
783
|
-
* `predicate`, short-circuiting on a match. Same bounded-scan discipline
|
|
784
|
-
* as queryAllPages(): a match past page one is still found, and a
|
|
785
|
-
* non-terminating scan throws StackQueryError.
|
|
43
|
+
* How far a change set's `parentId` will walk a proposed ancestor chain before refusing
|
|
44
|
+
* the move. Bounds the reads one call can cost; a hierarchy deeper than
|
|
45
|
+
* this is beyond what `parentId` is for. See docs/spec/data-model.md
|
|
46
|
+
* § Reparenting.
|
|
786
47
|
*/
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
do {
|
|
791
|
-
const page = await run({ ...query, cursor });
|
|
792
|
-
totalFetched += page.records.length;
|
|
793
|
-
if (totalFetched > max) {
|
|
794
|
-
throw new StackQueryError(`findFirstMatch: exceeded max of ${max} records without exhausting the cursor`);
|
|
795
|
-
}
|
|
796
|
-
for (const record of page.records) {
|
|
797
|
-
if (await predicate(record))
|
|
798
|
-
return record;
|
|
799
|
-
}
|
|
800
|
-
cursor = page.cursor ?? undefined;
|
|
801
|
-
} while (cursor);
|
|
802
|
-
return undefined;
|
|
803
|
-
}
|
|
48
|
+
const MAX_PARENT_DEPTH = 64;
|
|
49
|
+
/** Sentinel: filter.baseId resolved to zero matching types. */
|
|
50
|
+
const EMPTY_FAMILY = Symbol('empty-family');
|
|
804
51
|
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* hardcoded, so a caller not permitted to set it can omit it instead of
|
|
809
|
-
* having query() throw.
|
|
52
|
+
* The `_attachment@1` fields a write may not move, each with the message it
|
|
53
|
+
* is refused by. Ordered as reported. See docs/spec/attachments.md § The
|
|
54
|
+
* `_attachment` record type.
|
|
810
55
|
*/
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
...(includeUnlisted && { includeUnlisted: true }),
|
|
817
|
-
...(canFilterContent && { content: { did } }),
|
|
818
|
-
},
|
|
819
|
-
}, (r) => r.content.did === did);
|
|
820
|
-
return record ?? null;
|
|
821
|
-
}
|
|
56
|
+
const ATTACHMENT_IMMUTABLE_FIELDS = [
|
|
57
|
+
['mimeType', 'mimeType is immutable after creation; delete and re-upload to change it'],
|
|
58
|
+
['fileId', 'fileId is immutable'],
|
|
59
|
+
['size', 'size is immutable'],
|
|
60
|
+
];
|
|
822
61
|
// -------------------------------------------------------
|
|
823
62
|
// Stack class
|
|
824
63
|
// -------------------------------------------------------
|
|
@@ -1356,12 +595,7 @@ export class Stack {
|
|
|
1356
595
|
if (ops.length === 0)
|
|
1357
596
|
return existing;
|
|
1358
597
|
const previousParentId = existing.parentId ?? null;
|
|
1359
|
-
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops),
|
|
1360
|
-
expectedVersion: opts.ifVersion,
|
|
1361
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1362
|
-
updatedBy: opts.updatedBy,
|
|
1363
|
-
updatedVia: opts.updatedVia,
|
|
1364
|
-
});
|
|
598
|
+
const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), this.writeOptions(existing, opts));
|
|
1365
599
|
this.emitChange(ops, updated, ops.includes('reparent') ? { previousParentId } : {});
|
|
1366
600
|
return updated;
|
|
1367
601
|
}
|
|
@@ -1413,7 +647,13 @@ export class Stack {
|
|
|
1413
647
|
if (contentErrors.length > 0)
|
|
1414
648
|
throw new StackValidationError(contentErrors);
|
|
1415
649
|
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
1416
|
-
|
|
650
|
+
// Presence decides mimeType and value decides the rest: re-sending
|
|
651
|
+
// the mimeType a record already holds is refused outright, while a
|
|
652
|
+
// client round-tripping fileId or size unchanged has claimed nothing.
|
|
653
|
+
this.assertAttachmentImmutable((field) => Object.prototype.hasOwnProperty.call(contentPatch, field) &&
|
|
654
|
+
(field === 'mimeType' ||
|
|
655
|
+
merged[field] !==
|
|
656
|
+
existing.content[field]));
|
|
1417
657
|
}
|
|
1418
658
|
await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged);
|
|
1419
659
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
@@ -1475,12 +715,7 @@ export class Stack {
|
|
|
1475
715
|
if ((existing.associations ?? []).some((a) => associationEqual(a, association))) {
|
|
1476
716
|
return existing;
|
|
1477
717
|
}
|
|
1478
|
-
const updated = await this.adapter.associate(id, association,
|
|
1479
|
-
expectedVersion: opts.ifVersion,
|
|
1480
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1481
|
-
updatedBy: opts.updatedBy,
|
|
1482
|
-
updatedVia: opts.updatedVia,
|
|
1483
|
-
});
|
|
718
|
+
const updated = await this.adapter.associate(id, association, this.writeOptions(existing, opts));
|
|
1484
719
|
this.emitChange('associate', updated);
|
|
1485
720
|
return updated;
|
|
1486
721
|
}
|
|
@@ -1509,15 +744,28 @@ export class Stack {
|
|
|
1509
744
|
if (isGroupAdminAssociation(association)) {
|
|
1510
745
|
this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
|
|
1511
746
|
}
|
|
1512
|
-
const updated = await this.adapter.dissociate(id, association,
|
|
1513
|
-
expectedVersion: opts.ifVersion,
|
|
1514
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1515
|
-
updatedBy: opts.updatedBy,
|
|
1516
|
-
updatedVia: opts.updatedVia,
|
|
1517
|
-
});
|
|
747
|
+
const updated = await this.adapter.dissociate(id, association, this.writeOptions(existing, opts));
|
|
1518
748
|
this.emitChange('dissociate', updated);
|
|
1519
749
|
return updated;
|
|
1520
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* The checks a *caller-named* destination owes, before the cycle walk:
|
|
753
|
+
* `parentId` is a real, well-formed record id. Format first, so a
|
|
754
|
+
* malformed one is a 400 naming the problem rather than a read that
|
|
755
|
+
* cannot match. Existence closes the gap between the owner path and a
|
|
756
|
+
* non-owner's, where canReadReferent() already refuses a parent that
|
|
757
|
+
* isn't there.
|
|
758
|
+
*
|
|
759
|
+
* restoreVersion() deliberately does not call this — see its own comment.
|
|
760
|
+
* See docs/spec/data-model.md § Reparenting.
|
|
761
|
+
*/
|
|
762
|
+
async assertParentExists(id, parentId) {
|
|
763
|
+
validateParentId(parentId);
|
|
764
|
+
if (!(await this.adapter.getRecord(parentId))) {
|
|
765
|
+
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
|
|
766
|
+
'exist when it is named.');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
1521
769
|
/**
|
|
1522
770
|
* Refuse an edge that would make a record its own ancestor — asked at
|
|
1523
771
|
* every site that adds one: a change set naming `parentId`, a restore
|
|
@@ -1539,24 +787,6 @@ export class Stack {
|
|
|
1539
787
|
* the same posture applied here.
|
|
1540
788
|
* See docs/spec/data-model.md § Reparenting.
|
|
1541
789
|
*/
|
|
1542
|
-
/**
|
|
1543
|
-
* The checks a *caller-named* destination owes, before the cycle walk:
|
|
1544
|
-
* `parentId` is a real, well-formed record id. Format first, so a
|
|
1545
|
-
* malformed one is a 400 naming the problem rather than a read that
|
|
1546
|
-
* cannot match. Existence closes the gap between the owner path and a
|
|
1547
|
-
* non-owner's, where canReadReferent() already refuses a parent that
|
|
1548
|
-
* isn't there.
|
|
1549
|
-
*
|
|
1550
|
-
* restoreVersion() deliberately does not call this — see its own comment.
|
|
1551
|
-
* See docs/spec/data-model.md § Reparenting.
|
|
1552
|
-
*/
|
|
1553
|
-
async assertParentExists(id, parentId) {
|
|
1554
|
-
validateParentId(parentId);
|
|
1555
|
-
if (!(await this.adapter.getRecord(parentId))) {
|
|
1556
|
-
throw new StackConflictError(`Cannot parent record "${id}" to "${parentId}": no such record. A container has to ` +
|
|
1557
|
-
'exist when it is named.');
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
790
|
async assertNoParentCycle(id, parentId) {
|
|
1561
791
|
let cursor = parentId;
|
|
1562
792
|
for (let depth = 0; cursor !== undefined; depth++) {
|
|
@@ -1605,12 +835,7 @@ export class Stack {
|
|
|
1605
835
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1606
836
|
if (existing.deletedAt)
|
|
1607
837
|
return;
|
|
1608
|
-
const deleted = await this.adapter.deleteRecord(id,
|
|
1609
|
-
expectedVersion: opts.ifVersion,
|
|
1610
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1611
|
-
updatedBy: opts.updatedBy,
|
|
1612
|
-
updatedVia: opts.updatedVia,
|
|
1613
|
-
});
|
|
838
|
+
const deleted = await this.adapter.deleteRecord(id, this.writeOptions(existing, opts));
|
|
1614
839
|
if (deleted)
|
|
1615
840
|
this.emitChange('delete', deleted);
|
|
1616
841
|
}
|
|
@@ -1629,12 +854,7 @@ export class Stack {
|
|
|
1629
854
|
this.checkIfVersion(existing, opts.ifVersion);
|
|
1630
855
|
if (!existing.deletedAt)
|
|
1631
856
|
return existing;
|
|
1632
|
-
const undeleted = await this.adapter.undeleteRecord(id,
|
|
1633
|
-
expectedVersion: opts.ifVersion,
|
|
1634
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1635
|
-
updatedBy: opts.updatedBy,
|
|
1636
|
-
updatedVia: opts.updatedVia,
|
|
1637
|
-
});
|
|
857
|
+
const undeleted = await this.adapter.undeleteRecord(id, this.writeOptions(existing, opts));
|
|
1638
858
|
this.emitChange('undelete', undeleted);
|
|
1639
859
|
return undeleted;
|
|
1640
860
|
}
|
|
@@ -1753,10 +973,7 @@ export class Stack {
|
|
|
1753
973
|
// A `_group`'s roster is authority, not data: it does not roll back.
|
|
1754
974
|
// See docs/spec/versioning.md § Restore semantics.
|
|
1755
975
|
const restored = await this.adapter.restoreVersion(id, version, {
|
|
1756
|
-
|
|
1757
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1758
|
-
updatedBy: opts.updatedBy,
|
|
1759
|
-
updatedVia: opts.updatedVia,
|
|
976
|
+
...this.writeOptions(existing, opts),
|
|
1760
977
|
...(isGroupRecord(existing) && { restoreAssociations: false }),
|
|
1761
978
|
});
|
|
1762
979
|
this.emitChange('restore', restored, moves ? { previousParentId } : {});
|
|
@@ -1836,7 +1053,10 @@ export class Stack {
|
|
|
1836
1053
|
'Create the group instead.');
|
|
1837
1054
|
}
|
|
1838
1055
|
if (fromFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1839
|
-
|
|
1056
|
+
// Value-wise throughout: a migration re-sends all three required
|
|
1057
|
+
// fields, so presence would refuse every migration of the family.
|
|
1058
|
+
this.assertAttachmentImmutable((field) => content[field] !==
|
|
1059
|
+
existingContent[field]);
|
|
1840
1060
|
}
|
|
1841
1061
|
else if (toFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1842
1062
|
// A record arriving from outside the family stakes a fresh claim on
|
|
@@ -1847,12 +1067,7 @@ export class Stack {
|
|
|
1847
1067
|
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1848
1068
|
this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1849
1069
|
}
|
|
1850
|
-
const migrated = await this.adapter.commitMigration(id, toTypeId, content,
|
|
1851
|
-
expectedVersion: opts.ifVersion,
|
|
1852
|
-
snapshot: this.buildVersionSnapshot(existing),
|
|
1853
|
-
updatedBy: opts.updatedBy,
|
|
1854
|
-
updatedVia: opts.updatedVia,
|
|
1855
|
-
});
|
|
1070
|
+
const migrated = await this.adapter.commitMigration(id, toTypeId, content, this.writeOptions(existing, opts));
|
|
1856
1071
|
this.emitChange('migrate', migrated);
|
|
1857
1072
|
return migrated;
|
|
1858
1073
|
}
|
|
@@ -2002,56 +1217,20 @@ export class Stack {
|
|
|
2002
1217
|
}
|
|
2003
1218
|
}
|
|
2004
1219
|
/**
|
|
2005
|
-
* filename is the only mutable field on an _attachment@1 record; fileId,
|
|
2006
|
-
* size
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
1220
|
+
* filename is the only mutable field on an `_attachment@1` record; fileId,
|
|
1221
|
+
* size and mimeType are immutable, and the correction flow is delete +
|
|
1222
|
+
* re-upload. `violates` decides what counts as touching one, because the
|
|
1223
|
+
* two write shapes disagree: a patch names only what it changes, while a
|
|
1224
|
+
* migration replaces content wholesale and necessarily re-sends all three.
|
|
1225
|
+
*
|
|
1226
|
+
* Repointing `fileId` is the one that matters most: an `_attachment@1`
|
|
1227
|
+
* record naming a fileId is what canAccessFile()'s uploader clause reads,
|
|
1228
|
+
* so moving an existing record onto another file's hash is a route to
|
|
1229
|
+
* bytes the record's author never uploaded.
|
|
1230
|
+
* See docs/spec/attachments.md § The `_attachment` record type.
|
|
2009
1231
|
*/
|
|
2010
|
-
|
|
2011
|
-
const errors = [];
|
|
2012
|
-
if (Object.prototype.hasOwnProperty.call(patch, 'mimeType')) {
|
|
2013
|
-
errors.push({
|
|
2014
|
-
path: 'mimeType',
|
|
2015
|
-
message: 'mimeType is immutable after creation; delete and re-upload to change it',
|
|
2016
|
-
});
|
|
2017
|
-
}
|
|
2018
|
-
if (Object.prototype.hasOwnProperty.call(patch, 'fileId') &&
|
|
2019
|
-
merged.fileId !== existing.fileId) {
|
|
2020
|
-
errors.push({ path: 'fileId', message: 'fileId is immutable' });
|
|
2021
|
-
}
|
|
2022
|
-
if (Object.prototype.hasOwnProperty.call(patch, 'size') && merged.size !== existing.size) {
|
|
2023
|
-
errors.push({ path: 'size', message: 'size is immutable' });
|
|
2024
|
-
}
|
|
2025
|
-
if (errors.length > 0) {
|
|
2026
|
-
throw new StackValidationError(errors);
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
/**
|
|
2030
|
-
* The same immutability checkAttachmentImmutableFields() enforces, asked
|
|
2031
|
-
* value-wise instead of presence-wise: a migration replaces content
|
|
2032
|
-
* wholesale, so it necessarily re-sends `mimeType`, `fileId` and `size`
|
|
2033
|
-
* (all required) and a presence check would refuse every migration. Only
|
|
2034
|
-
* an actual change is a violation.
|
|
2035
|
-
*
|
|
2036
|
-
* Repointing `fileId` is the one that matters most: an `_attachment@1`
|
|
2037
|
-
* record naming a fileId is what canAccessFile()'s uploader clause reads,
|
|
2038
|
-
* so moving an existing record onto another file's hash is a route to
|
|
2039
|
-
* bytes the record's author never uploaded.
|
|
2040
|
-
*/
|
|
2041
|
-
checkAttachmentImmutableOnMigrate(existing, next) {
|
|
2042
|
-
const errors = [];
|
|
2043
|
-
if (next.mimeType !== existing.mimeType) {
|
|
2044
|
-
errors.push({
|
|
2045
|
-
path: 'mimeType',
|
|
2046
|
-
message: 'mimeType is immutable after creation; delete and re-upload to change it',
|
|
2047
|
-
});
|
|
2048
|
-
}
|
|
2049
|
-
if (next.fileId !== existing.fileId) {
|
|
2050
|
-
errors.push({ path: 'fileId', message: 'fileId is immutable' });
|
|
2051
|
-
}
|
|
2052
|
-
if (next.size !== existing.size) {
|
|
2053
|
-
errors.push({ path: 'size', message: 'size is immutable' });
|
|
2054
|
-
}
|
|
1232
|
+
assertAttachmentImmutable(violates) {
|
|
1233
|
+
const errors = ATTACHMENT_IMMUTABLE_FIELDS.filter(([field]) => violates(field)).map(([path, message]) => ({ path, message }));
|
|
2055
1234
|
if (errors.length > 0) {
|
|
2056
1235
|
throw new StackValidationError(errors);
|
|
2057
1236
|
}
|
|
@@ -2262,12 +1441,6 @@ export class Stack {
|
|
|
2262
1441
|
// -------------------------------------------------------
|
|
2263
1442
|
// Lifecycle
|
|
2264
1443
|
// -------------------------------------------------------
|
|
2265
|
-
/**
|
|
2266
|
-
* Flush pending writes to the underlying storage. A no-op for adapters
|
|
2267
|
-
* that commit on every call (SQLite, the API adapter); meaningful for
|
|
2268
|
-
* ones that buffer, and for checkpointing a stack that stays open —
|
|
2269
|
-
* close() covers the teardown case on its own.
|
|
2270
|
-
*/
|
|
2271
1444
|
/**
|
|
2272
1445
|
* Observe every change made through this Stack. Unscoped, so no
|
|
2273
1446
|
* permission filter applies — a caller holding a `Stack` already reaches
|
|
@@ -2332,6 +1505,12 @@ export class Stack {
|
|
|
2332
1505
|
get relaysChanges() {
|
|
2333
1506
|
return typeof this.adapter.subscribeChanges === 'function';
|
|
2334
1507
|
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Flush pending writes to the underlying storage. A no-op for adapters
|
|
1510
|
+
* that commit on every call (SQLite, the API adapter); meaningful for
|
|
1511
|
+
* ones that buffer, and for checkpointing a stack that stays open —
|
|
1512
|
+
* close() covers the teardown case on its own.
|
|
1513
|
+
*/
|
|
2335
1514
|
async flush() {
|
|
2336
1515
|
this.assertOpen();
|
|
2337
1516
|
await this.adapter.flush?.();
|
|
@@ -2412,9 +1591,7 @@ export class Stack {
|
|
|
2412
1591
|
this.assertOpen();
|
|
2413
1592
|
if (target !== undefined)
|
|
2414
1593
|
validateGrantTarget(target);
|
|
2415
|
-
const all = await
|
|
2416
|
-
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
2417
|
-
});
|
|
1594
|
+
const all = await loadGrantRecords((q) => this.query(q));
|
|
2418
1595
|
if (target === undefined)
|
|
2419
1596
|
return all;
|
|
2420
1597
|
if (target === null || typeof target === 'object') {
|
|
@@ -2447,9 +1624,7 @@ export class Stack {
|
|
|
2447
1624
|
async revoke(target, grants) {
|
|
2448
1625
|
this.assertOpen();
|
|
2449
1626
|
validateGrantTarget(target);
|
|
2450
|
-
const all = await
|
|
2451
|
-
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
2452
|
-
});
|
|
1627
|
+
const all = await loadGrantRecords((q) => this.query(q));
|
|
2453
1628
|
for (const g of grants) {
|
|
2454
1629
|
const familyId = baseIdOf(g.typeId);
|
|
2455
1630
|
const actionSet = new Set(g.actions);
|
|
@@ -2569,6 +1744,22 @@ export class Stack {
|
|
|
2569
1744
|
return;
|
|
2570
1745
|
throw new StackVersionConflictError(`Record "${existing.id}" is at version ${existing.version}, expected ${ifVersion}`, existing.id, ifVersion, existing.version);
|
|
2571
1746
|
}
|
|
1747
|
+
/**
|
|
1748
|
+
* The options every version-bumping adapter write carries: the `ifVersion`
|
|
1749
|
+
* precondition, the prior-state snapshot that has to land in the same
|
|
1750
|
+
* atomic write, and who to attribute the change to. Taken together so a
|
|
1751
|
+
* new mutating verb cannot quietly omit one — `updatedVia` most of all,
|
|
1752
|
+
* whose absence reads as an undelegated write.
|
|
1753
|
+
* See docs/spec/versioning.md § Version history.
|
|
1754
|
+
*/
|
|
1755
|
+
writeOptions(existing, opts) {
|
|
1756
|
+
return {
|
|
1757
|
+
expectedVersion: opts.ifVersion,
|
|
1758
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
1759
|
+
updatedBy: opts.updatedBy,
|
|
1760
|
+
updatedVia: opts.updatedVia,
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
2572
1763
|
/**
|
|
2573
1764
|
* Snapshot of a record's prior state, passed with the mutating adapter
|
|
2574
1765
|
* call so snapshot and mutation land in one atomic write. `associations`
|
|
@@ -2592,1347 +1783,4 @@ export class Stack {
|
|
|
2592
1783
|
};
|
|
2593
1784
|
}
|
|
2594
1785
|
}
|
|
2595
|
-
// -------------------------------------------------------
|
|
2596
|
-
// Equality helpers
|
|
2597
|
-
// -------------------------------------------------------
|
|
2598
|
-
/**
|
|
2599
|
-
* Matches the SQLite adapter's association primary key (kind, label,
|
|
2600
|
-
* file_id, related_scope, related_id, related_ns, related_stack).
|
|
2601
|
-
*/
|
|
2602
|
-
function associationEqual(a, b) {
|
|
2603
|
-
if (a.kind !== b.kind || a.label !== b.label)
|
|
2604
|
-
return false;
|
|
2605
|
-
if (a.kind === 'attachment' && b.kind === 'attachment')
|
|
2606
|
-
return a.fileId === b.fileId;
|
|
2607
|
-
if (a.kind === 'relationship' && b.kind === 'relationship') {
|
|
2608
|
-
return targetEqual(a.target, b.target);
|
|
2609
|
-
}
|
|
2610
|
-
return true;
|
|
2611
|
-
}
|
|
2612
|
-
/** Structural equality per target arm — what dissociate() matches on. */
|
|
2613
|
-
export function targetEqual(a, b) {
|
|
2614
|
-
if (a.scope !== b.scope)
|
|
2615
|
-
return false;
|
|
2616
|
-
if (a.scope === 'record' && b.scope === 'record') {
|
|
2617
|
-
return a.recordId === b.recordId && (a.stackUrl ?? '') === (b.stackUrl ?? '');
|
|
2618
|
-
}
|
|
2619
|
-
if (a.scope === 'entity' && b.scope === 'entity')
|
|
2620
|
-
return a.entityId === b.entityId;
|
|
2621
|
-
if (a.scope === 'external' && b.scope === 'external')
|
|
2622
|
-
return a.ns === b.ns && a.id === b.id;
|
|
2623
|
-
return false;
|
|
2624
|
-
}
|
|
2625
|
-
function permissionEqual(a, b) {
|
|
2626
|
-
if (a.access !== b.access)
|
|
2627
|
-
return false;
|
|
2628
|
-
if (a.access === 'public')
|
|
2629
|
-
return true;
|
|
2630
|
-
if (a.access === 'entity' && b.access === 'entity') {
|
|
2631
|
-
return a.entityId === b.entityId && a.read === b.read && a.write === b.write;
|
|
2632
|
-
}
|
|
2633
|
-
if (a.access === 'group' && b.access === 'group') {
|
|
2634
|
-
return a.groupId === b.groupId && a.role === b.role && a.read === b.read && a.write === b.write;
|
|
2635
|
-
}
|
|
2636
|
-
return false;
|
|
2637
|
-
}
|
|
2638
|
-
/**
|
|
2639
|
-
* A change set has to name at least one aspect. Refused rather than read
|
|
2640
|
-
* as a no-op: it addresses nothing, so there is nothing it could have
|
|
2641
|
-
* failed to satisfy, and every way of producing one is a caller bug —
|
|
2642
|
-
* typically a conditional that built an empty object.
|
|
2643
|
-
* See docs/spec/data-model.md § Mutations.
|
|
2644
|
-
*/
|
|
2645
|
-
function assertNonEmptyChangeSet(changes) {
|
|
2646
|
-
// Presence, not truthiness: `unlisted: false` and `parentId: null` are
|
|
2647
|
-
// aspects this call names, and reading them as absent would drop a
|
|
2648
|
-
// change the caller asked for.
|
|
2649
|
-
if (RECORD_CHANGE_KEYS.some((key) => changes[key] !== undefined))
|
|
2650
|
-
return;
|
|
2651
|
-
throw new StackQueryError('A change set names at least one of: ' + RECORD_CHANGE_KEYS.join(', ') + '.');
|
|
2652
|
-
}
|
|
2653
|
-
/**
|
|
2654
|
-
* Which aspects a change set actually moves, against the record as it
|
|
2655
|
-
* stands — the no-op decision and the change event's `ops` are the same
|
|
2656
|
-
* comparison, so they can never disagree. A key naming the value a record
|
|
2657
|
-
* already holds contributes nothing.
|
|
2658
|
-
*
|
|
2659
|
-
* `merged` is the content the patch produces, computed once by the
|
|
2660
|
-
* caller that had to validate it anyway.
|
|
2661
|
-
* See docs/spec/events.md § The event shape.
|
|
2662
|
-
*/
|
|
2663
|
-
function changeSetOps(existing, changes, merged) {
|
|
2664
|
-
const ops = [];
|
|
2665
|
-
if (merged !== undefined && !contentEqual(existing.content, merged))
|
|
2666
|
-
ops.push('patch');
|
|
2667
|
-
if (changes.parentId !== undefined && changes.parentId !== (existing.parentId ?? null)) {
|
|
2668
|
-
ops.push('reparent');
|
|
2669
|
-
}
|
|
2670
|
-
if (changes.permissions && !permissionsEqual(existing.permissions ?? [], changes.permissions)) {
|
|
2671
|
-
ops.push('permissions');
|
|
2672
|
-
}
|
|
2673
|
-
if (changes.associations) {
|
|
2674
|
-
const before = existing.associations ?? [];
|
|
2675
|
-
const after = changes.associations;
|
|
2676
|
-
if (after.some((a) => !before.some((b) => associationEqual(a, b))))
|
|
2677
|
-
ops.push('associate');
|
|
2678
|
-
if (before.some((b) => !after.some((a) => associationEqual(a, b))))
|
|
2679
|
-
ops.push('dissociate');
|
|
2680
|
-
}
|
|
2681
|
-
if (changes.unlisted !== undefined && Boolean(existing.unlistedAt) !== changes.unlisted) {
|
|
2682
|
-
ops.push(changes.unlisted ? 'unlist' : 'list');
|
|
2683
|
-
}
|
|
2684
|
-
return ops;
|
|
2685
|
-
}
|
|
2686
|
-
/**
|
|
2687
|
-
* The change set narrowed to the aspects that actually moved. An adapter
|
|
2688
|
-
* is handed this rather than what the caller wrote, so restating an aspect
|
|
2689
|
-
* cannot rewrite it: `unlisted: true` on an already-unlisted record would
|
|
2690
|
-
* otherwise drag `unlistedAt` forward, moving the record's publish moment
|
|
2691
|
-
* with no op reporting it. It also keeps the adapter contract honest —
|
|
2692
|
-
* every key an adapter receives is one it must write.
|
|
2693
|
-
*/
|
|
2694
|
-
function effectiveChanges(changes, ops) {
|
|
2695
|
-
const effective = {};
|
|
2696
|
-
if (ops.includes('patch'))
|
|
2697
|
-
effective.contentPatch = changes.contentPatch;
|
|
2698
|
-
if (ops.includes('reparent'))
|
|
2699
|
-
effective.parentId = changes.parentId;
|
|
2700
|
-
if (ops.includes('permissions'))
|
|
2701
|
-
effective.permissions = changes.permissions;
|
|
2702
|
-
if (ops.includes('associate') || ops.includes('dissociate')) {
|
|
2703
|
-
effective.associations = changes.associations;
|
|
2704
|
-
}
|
|
2705
|
-
if (ops.includes('unlist') || ops.includes('list'))
|
|
2706
|
-
effective.unlisted = changes.unlisted;
|
|
2707
|
-
return effective;
|
|
2708
|
-
}
|
|
2709
|
-
/**
|
|
2710
|
-
* Whether a merge patch produced the content the record already held.
|
|
2711
|
-
* Compared by serialization: content is JSON by construction — it round
|
|
2712
|
-
* trips through storage that way — and a patch preserves key order for
|
|
2713
|
-
* every field it does not name, so the encoding of an unchanged record is
|
|
2714
|
-
* stable. A reordering patch that changes nothing else is the one case
|
|
2715
|
-
* this reports as a change, which costs an empty version rather than a
|
|
2716
|
-
* wrong answer.
|
|
2717
|
-
*/
|
|
2718
|
-
function contentEqual(a, b) {
|
|
2719
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
2720
|
-
}
|
|
2721
|
-
function permissionsEqual(a, b) {
|
|
2722
|
-
return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
|
|
2723
|
-
}
|
|
2724
|
-
// -------------------------------------------------------
|
|
2725
|
-
// ScopedStack
|
|
2726
|
-
// -------------------------------------------------------
|
|
2727
|
-
/** Default page size used to fill a permission-filtered query result. */
|
|
2728
|
-
const DEFAULT_QUERY_LIMIT = 50;
|
|
2729
|
-
const MAX_QUERY_LIMIT = 1000;
|
|
2730
|
-
/**
|
|
2731
|
-
* How far a change set's `parentId` will walk a proposed ancestor chain before refusing
|
|
2732
|
-
* the move. Bounds the reads one call can cost; a hierarchy deeper than
|
|
2733
|
-
* this is beyond what `parentId` is for. See docs/spec/data-model.md
|
|
2734
|
-
* § Reparenting.
|
|
2735
|
-
*/
|
|
2736
|
-
const MAX_PARENT_DEPTH = 64;
|
|
2737
|
-
/**
|
|
2738
|
-
* Ensures `creator` carries an `admin` relationship association, adding one
|
|
2739
|
-
* if it's not already present. Used to bootstrap a `_group` record's first
|
|
2740
|
-
* admin at create time.
|
|
2741
|
-
*/
|
|
2742
|
-
function stampGroupAdmin(associations, creator) {
|
|
2743
|
-
const list = associations ?? [];
|
|
2744
|
-
const alreadyAdmin = list.some((a) => a.kind === 'relationship' &&
|
|
2745
|
-
a.label === 'admin' &&
|
|
2746
|
-
a.target.scope === 'entity' &&
|
|
2747
|
-
a.target.entityId === creator);
|
|
2748
|
-
if (alreadyAdmin)
|
|
2749
|
-
return list;
|
|
2750
|
-
return [
|
|
2751
|
-
...list,
|
|
2752
|
-
{ kind: 'relationship', label: 'admin', target: { scope: 'entity', entityId: creator } },
|
|
2753
|
-
];
|
|
2754
|
-
}
|
|
2755
|
-
/**
|
|
2756
|
-
* Whether a Record is a `_group`, in any of its type versions — the family
|
|
2757
|
-
* whose roster rules apply.
|
|
2758
|
-
*/
|
|
2759
|
-
function isGroupRecord(record) {
|
|
2760
|
-
return baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP;
|
|
2761
|
-
}
|
|
2762
|
-
/**
|
|
2763
|
-
* Drops a snapshot's `permissions` — owner-only audit data, never served
|
|
2764
|
-
* to a non-owner history reader. `entityId` (change attribution) stays.
|
|
2765
|
-
* See docs/spec/versioning.md § History access.
|
|
2766
|
-
*/
|
|
2767
|
-
function stripVersionPermissions(version) {
|
|
2768
|
-
if (version.permissions === undefined)
|
|
2769
|
-
return version;
|
|
2770
|
-
const { permissions: _permissions, ...rest } = version;
|
|
2771
|
-
return rest;
|
|
2772
|
-
}
|
|
2773
|
-
/**
|
|
2774
|
-
* The tombstone a soft-deleted Record is presented as. `permissions` is
|
|
2775
|
-
* retained because it decides whether the caller may undelete; history is
|
|
2776
|
-
* exempt, which is why getVersions() still serves the content this
|
|
2777
|
-
* withholds. See docs/spec/versioning.md § The tombstone is literal.
|
|
2778
|
-
*/
|
|
2779
|
-
function tombstoneOf(record) {
|
|
2780
|
-
return {
|
|
2781
|
-
id: record.id,
|
|
2782
|
-
typeId: record.typeId,
|
|
2783
|
-
createdAt: record.createdAt,
|
|
2784
|
-
updatedAt: record.updatedAt,
|
|
2785
|
-
version: record.version,
|
|
2786
|
-
content: {},
|
|
2787
|
-
...(record.deletedAt !== undefined && { deletedAt: record.deletedAt }),
|
|
2788
|
-
...(record.unlistedAt !== undefined && { unlistedAt: record.unlistedAt }),
|
|
2789
|
-
...(record.permissions !== undefined && { permissions: record.permissions }),
|
|
2790
|
-
};
|
|
2791
|
-
}
|
|
2792
|
-
/** A soft-deleted Record presented as its tombstone; anything else untouched. */
|
|
2793
|
-
const presentDeleted = (record) => record.deletedAt ? tombstoneOf(record) : record;
|
|
2794
|
-
/**
|
|
2795
|
-
* A permission-enforcing view of a Stack for a single (principal, subject)
|
|
2796
|
-
* pair, obtained via `stack.asEntity(entityId)`. A record the request
|
|
2797
|
-
* cannot read answers exactly as a missing one does — null on reads,
|
|
2798
|
-
* StackNotFoundError on the verbs that name one — so only a requester who
|
|
2799
|
-
* could have read it is told a refusal was about access.
|
|
2800
|
-
* See docs/spec/access-control.md § Errors and information exposure.
|
|
2801
|
-
*
|
|
2802
|
-
* Two identities, one rule: **the principal governs authority, the subject
|
|
2803
|
-
* governs attribution.** Grant lookup and the privilege-bearing gates that
|
|
2804
|
-
* no grant reaches (resharing, group management, hard delete, widening
|
|
2805
|
-
* access at create time) key on `principalEntityId`; authorship, `-own`
|
|
2806
|
-
* matching, record-level permission resolution, and "files I uploaded"
|
|
2807
|
-
* lookups key on `subjectEntityId`.
|
|
2808
|
-
*
|
|
2809
|
-
* Unconditional owner access follows that same split rather than one
|
|
2810
|
-
* identity: it answers *what data is reachable* for the subject (an owner
|
|
2811
|
-
* subject resolves past every permission check) and *who may exercise a
|
|
2812
|
-
* privileged verb* for the principal (an owner app is not bounded by
|
|
2813
|
-
* grants). Under delegation both halves apply, and a mistake on the
|
|
2814
|
-
* authority side is an escalation rather than a preference.
|
|
2815
|
-
*/
|
|
2816
|
-
/**
|
|
2817
|
-
* The authority lookups canRead needs, held for the life of one
|
|
2818
|
-
* subscription. A subscription is long-lived where a query is not, so the
|
|
2819
|
-
* cache is only safe because every write that can change canRead's answer
|
|
2820
|
-
* arrives as an event that drops it — see ScopedSubscription.
|
|
2821
|
-
*/
|
|
2822
|
-
class FeedAuthorityCache {
|
|
2823
|
-
grantRecords = null;
|
|
2824
|
-
/**
|
|
2825
|
-
* Bumped by every invalidation, so a load that was already in flight can
|
|
2826
|
-
* tell that its result is stale before seating it.
|
|
2827
|
-
*/
|
|
2828
|
-
generation = 0;
|
|
2829
|
-
/** Roster roles, memoized per group, as ScopedStack.query() does per query. */
|
|
2830
|
-
roles = new Map();
|
|
2831
|
-
/** Every `_grant` record, refilled through `load` after an invalidation. */
|
|
2832
|
-
async grants(load) {
|
|
2833
|
-
if (this.grantRecords !== null)
|
|
2834
|
-
return this.grantRecords;
|
|
2835
|
-
const generation = this.generation;
|
|
2836
|
-
const loaded = await load();
|
|
2837
|
-
// An invalidation during the load already dropped the set these
|
|
2838
|
-
// replace, so seating them would outlive the write that expired them
|
|
2839
|
-
// and no later event would drop them again. The event being decided
|
|
2840
|
-
// precedes that write, so it is still decided on what was loaded.
|
|
2841
|
-
if (this.generation === generation)
|
|
2842
|
-
this.grantRecords = loaded;
|
|
2843
|
-
return loaded;
|
|
2844
|
-
}
|
|
2845
|
-
invalidateFor(typeFamily) {
|
|
2846
|
-
if (typeFamily === SYSTEM_TYPES.GRANT) {
|
|
2847
|
-
this.grantRecords = null;
|
|
2848
|
-
this.generation++;
|
|
2849
|
-
}
|
|
2850
|
-
if (typeFamily === SYSTEM_TYPES.GROUP)
|
|
2851
|
-
this.roles = new Map();
|
|
2852
|
-
}
|
|
2853
|
-
}
|
|
2854
|
-
/**
|
|
2855
|
-
* A ScopedStack's delivery: canRead per event, with the grant and roster
|
|
2856
|
-
* lookups it needs cached for the life of the subscription and dropped the
|
|
2857
|
-
* moment anything that feeds them changes.
|
|
2858
|
-
*
|
|
2859
|
-
* Two properties do the work, and neither is optional:
|
|
2860
|
-
*
|
|
2861
|
-
* **Deliveries are serialized.** The permission decision is asynchronous,
|
|
2862
|
-
* so without a queue two changes to one record could resolve out of order
|
|
2863
|
-
* and be delivered newest-first — breaking the one ordering guarantee the
|
|
2864
|
-
* feed makes. Every emission is appended to a chain instead.
|
|
2865
|
-
*
|
|
2866
|
-
* **The filter fails closed.** A permission check that throws drops the
|
|
2867
|
-
* event and reports the error; it never delivers on the assumption that a
|
|
2868
|
-
* failed check would have passed.
|
|
2869
|
-
*
|
|
2870
|
-
* See docs/spec/events.md § Permission scoping.
|
|
2871
|
-
*/
|
|
2872
|
-
class ScopedSubscription extends Subscription {
|
|
2873
|
-
canRead;
|
|
2874
|
-
cache = new FeedAuthorityCache();
|
|
2875
|
-
/** Tail of the delivery chain — see the class comment. */
|
|
2876
|
-
queue = Promise.resolve();
|
|
2877
|
-
constructor(canRead, handler, opts) {
|
|
2878
|
-
super(handler, opts);
|
|
2879
|
-
this.canRead = canRead;
|
|
2880
|
-
}
|
|
2881
|
-
accept(emission) {
|
|
2882
|
-
// Invalidation reads every emission, including the ones this
|
|
2883
|
-
// subscriber may not see: a revocation the subscriber cannot read is
|
|
2884
|
-
// exactly the one that must still expire its cache.
|
|
2885
|
-
this.invalidateFor(emission);
|
|
2886
|
-
if (!matchesFilter(emission, this.opts.filter))
|
|
2887
|
-
return;
|
|
2888
|
-
this.queue = this.queue.then(() => this.filterAndDeliver(emission));
|
|
2889
|
-
}
|
|
2890
|
-
/**
|
|
2891
|
-
* A cached grant set outlives the write that revokes it unless something
|
|
2892
|
-
* drops it. Both writes that can change canRead's answer — a `_grant`
|
|
2893
|
-
* record, or a `_group` roster — arrive here as ordinary events, which
|
|
2894
|
-
* is what makes the cache safe to hold at all. A future authority change
|
|
2895
|
-
* that did not emit would silently strand it.
|
|
2896
|
-
*/
|
|
2897
|
-
invalidateFor(emission) {
|
|
2898
|
-
this.cache.invalidateFor(baseIdOf(emission.change.typeId));
|
|
2899
|
-
}
|
|
2900
|
-
async filterAndDeliver(emission) {
|
|
2901
|
-
if (this.isClosed)
|
|
2902
|
-
return;
|
|
2903
|
-
if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted))
|
|
2904
|
-
return;
|
|
2905
|
-
try {
|
|
2906
|
-
if (!(await this.canRead(emission.record, this.cache)))
|
|
2907
|
-
return;
|
|
2908
|
-
}
|
|
2909
|
-
catch (err) {
|
|
2910
|
-
// Fail closed: an undecided permission question is not a yes.
|
|
2911
|
-
this.reportError(err);
|
|
2912
|
-
return;
|
|
2913
|
-
}
|
|
2914
|
-
// After the permission decision, which needs the whole record: a frame
|
|
2915
|
-
// carrying a deleted record's body would be a read channel around the
|
|
2916
|
-
// tombstone. See docs/spec/events.md § Soft-deleted records reach the
|
|
2917
|
-
// feed as tombstones.
|
|
2918
|
-
this.deliver(this.project(emission));
|
|
2919
|
-
}
|
|
2920
|
-
project(emission) {
|
|
2921
|
-
const record = presentDeleted(emission.record);
|
|
2922
|
-
return record === emission.record ? emission : { ...emission, record };
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
export class ScopedStack {
|
|
2926
|
-
stack;
|
|
2927
|
-
principalEntityId;
|
|
2928
|
-
subjectEntityId;
|
|
2929
|
-
idTimestampSkewMs;
|
|
2930
|
-
adapter;
|
|
2931
|
-
changes;
|
|
2932
|
-
constructor(stack, principalEntityId, subjectEntityId, idTimestampSkewMs,
|
|
2933
|
-
// Bytes-storage primitive for putAttachment(). Held directly because
|
|
2934
|
-
// ScopedStack always composes bytes + its own create() — the record
|
|
2935
|
-
// must carry the subject's entityId and the principal behind it,
|
|
2936
|
-
// neither of which the adapter-level atomic capability takes.
|
|
2937
|
-
adapter,
|
|
2938
|
-
// The stack's own stream, filtered by subscribe(). Held here rather
|
|
2939
|
-
// than reached through a method on Stack so the unfiltered stream
|
|
2940
|
-
// stays inside this module.
|
|
2941
|
-
changes) {
|
|
2942
|
-
this.stack = stack;
|
|
2943
|
-
this.principalEntityId = principalEntityId;
|
|
2944
|
-
this.subjectEntityId = subjectEntityId;
|
|
2945
|
-
this.idTimestampSkewMs = idTimestampSkewMs;
|
|
2946
|
-
this.adapter = adapter;
|
|
2947
|
-
this.changes = changes;
|
|
2948
|
-
}
|
|
2949
|
-
get features() {
|
|
2950
|
-
return this.stack.features;
|
|
2951
|
-
}
|
|
2952
|
-
resolveRecord = (id) => this.stack.get(id);
|
|
2953
|
-
/** Whether a delegated app is acting for someone other than itself. */
|
|
2954
|
-
get delegated() {
|
|
2955
|
-
return this.subjectEntityId !== this.principalEntityId;
|
|
2956
|
-
}
|
|
2957
|
-
/**
|
|
2958
|
-
* Who this request is, for stamping onto whatever it mutates. Attribution
|
|
2959
|
-
* follows record-level authorship: the subject is the actor, and the
|
|
2960
|
-
* principal is named beside it only when the two differ.
|
|
2961
|
-
* See docs/spec/data-model.md § Authorship and attribution.
|
|
2962
|
-
*/
|
|
2963
|
-
get actor() {
|
|
2964
|
-
// Both keys are always present, so spreading this last overrides
|
|
2965
|
-
// anything a caller passed: a scoped requester names itself by making
|
|
2966
|
-
// the request, never by describing itself in the options.
|
|
2967
|
-
return {
|
|
2968
|
-
updatedBy: this.subjectEntityId ?? undefined,
|
|
2969
|
-
updatedVia: this.delegated ? (this.principalEntityId ?? undefined) : undefined,
|
|
2970
|
-
};
|
|
2971
|
-
}
|
|
2972
|
-
/**
|
|
2973
|
-
* Whether unconditional owner authority applies — the owner acting as
|
|
2974
|
-
* itself. The verbs that rest on it are irreversible or disclose the
|
|
2975
|
-
* sharing graph, so delegation never carries one to a subject, whichever
|
|
2976
|
-
* side the owner is on. Shared with the predicate a server applies to a
|
|
2977
|
-
* session, which decides the same tier one step earlier. See
|
|
2978
|
-
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
2979
|
-
*/
|
|
2980
|
-
get ownerActingAlone() {
|
|
2981
|
-
return isOwnerActingAlone({ principalId: this.principalEntityId, subjectId: this.subjectEntityId }, this.stack.ownerEntityId);
|
|
2982
|
-
}
|
|
2983
|
-
checkRead(record) {
|
|
2984
|
-
return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'read', this.resolveRecord);
|
|
2985
|
-
}
|
|
2986
|
-
checkWrite(record) {
|
|
2987
|
-
return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'write', this.resolveRecord);
|
|
2988
|
-
}
|
|
2989
|
-
/**
|
|
2990
|
-
* Whether `grantee` holds a _grant covering one of `actions` for the
|
|
2991
|
-
* type's family (grants match by baseId, so a version bump never orphans
|
|
2992
|
-
* one). -own actions additionally require record.entityId === grantee,
|
|
2993
|
-
* unless `matchOwn` is false — on the principal side of a delegated
|
|
2994
|
-
* request the suffix is read as the bare verb, since which records are
|
|
2995
|
-
* reachable is the subject's business. `allowDefault` decides whether a
|
|
2996
|
-
* grant naming nobody counts. Anonymous grantees always return false.
|
|
2997
|
-
*
|
|
2998
|
-
* Reached only through subjectAllows()/principalAllows(), which fix those
|
|
2999
|
-
* two flags per side of the intersection. Call one of those instead.
|
|
3000
|
-
* See docs/spec/access-control.md § Type-level grants.
|
|
3001
|
-
*/
|
|
3002
|
-
async hasGrant(typeId, actions, opts) {
|
|
3003
|
-
const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
|
|
3004
|
-
if (!grantee)
|
|
3005
|
-
return false;
|
|
3006
|
-
// Absent when the caller has no operation-scoped map to share — one
|
|
3007
|
-
// call's worth of memoization, which is still every grant record in
|
|
3008
|
-
// this loop naming the same group.
|
|
3009
|
-
const groupRoles = opts.groupRoles ?? new Map();
|
|
3010
|
-
const familyId = baseIdOf(typeId);
|
|
3011
|
-
// grant() refuses to write these, but a _grant record is an ordinary
|
|
3012
|
-
// Record: an unscoped Stack, an import, or a server mapping a request
|
|
3013
|
-
// body onto Stack can mint one anyway. Refusing at the point of use is
|
|
3014
|
-
// what makes the rule hold regardless of how the record got there.
|
|
3015
|
-
if (UNGRANTABLE_SYSTEM_TYPES.has(familyId))
|
|
3016
|
-
return false;
|
|
3017
|
-
let grantRecords;
|
|
3018
|
-
if (prefetchedGrants !== undefined) {
|
|
3019
|
-
grantRecords = prefetchedGrants;
|
|
3020
|
-
}
|
|
3021
|
-
else {
|
|
3022
|
-
// No content-field prefilter: a stored grant's typeId may be a bare
|
|
3023
|
-
// baseId or versioned, so exact matching would wrongly exclude family
|
|
3024
|
-
// versions. Cursor-walked to see grants past page one.
|
|
3025
|
-
grantRecords = await queryAllPages((q) => this.stack.query(q), {
|
|
3026
|
-
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
3027
|
-
});
|
|
3028
|
-
}
|
|
3029
|
-
for (const r of grantRecords) {
|
|
3030
|
-
const c = r.content;
|
|
3031
|
-
if (baseIdOf(c.typeId) !== familyId)
|
|
3032
|
-
continue;
|
|
3033
|
-
const covers = await grantCoversGrantee(c, grantee, {
|
|
3034
|
-
allowDefault,
|
|
3035
|
-
allowGroup,
|
|
3036
|
-
groupRoles,
|
|
3037
|
-
resolveRecord: this.resolveRecord,
|
|
3038
|
-
});
|
|
3039
|
-
if (!covers)
|
|
3040
|
-
continue;
|
|
3041
|
-
const matches = actions.some((action) => {
|
|
3042
|
-
if (!grantConveys(c.actions, action))
|
|
3043
|
-
return false;
|
|
3044
|
-
if (matchOwn && action.endsWith('-own'))
|
|
3045
|
-
return record?.entityId === grantee;
|
|
3046
|
-
return true;
|
|
3047
|
-
});
|
|
3048
|
-
if (matches)
|
|
3049
|
-
return true;
|
|
3050
|
-
}
|
|
3051
|
-
return false;
|
|
3052
|
-
}
|
|
3053
|
-
/**
|
|
3054
|
-
* The principal half of a delegated request's authority: does the app
|
|
3055
|
-
* hold any grant permitting these verbs on this type at all. Bounds what
|
|
3056
|
-
* the subject's own authority can reach through it, so a powerful app
|
|
3057
|
-
* can never lend its reach to a weaker subject — nor the reverse.
|
|
3058
|
-
* Vacuously true when there's no delegation, where the principal and
|
|
3059
|
-
* subject checks would be the same question asked twice.
|
|
3060
|
-
*
|
|
3061
|
-
* Default grants don't count here. "Any authenticated entity" is about
|
|
3062
|
-
* people who turn up, not software the owner installed — an app reaches
|
|
3063
|
-
* only the types named to it, which is the whole of what containment
|
|
3064
|
-
* promises.
|
|
3065
|
-
*
|
|
3066
|
-
* Group-targeted grants don't count here either, one step removed: a
|
|
3067
|
-
* roster is editable by any of the group's admins, so authority reaching
|
|
3068
|
-
* a principal through one would let someone other than the owner name an
|
|
3069
|
-
* app to a type. See docs/spec/access-control.md § Type-level grants.
|
|
3070
|
-
*/
|
|
3071
|
-
principalAllows(typeId, actions, prefetchedGrants) {
|
|
3072
|
-
if (!this.delegated)
|
|
3073
|
-
return Promise.resolve(true);
|
|
3074
|
-
if (this.principalEntityId === this.stack.ownerEntityId)
|
|
3075
|
-
return Promise.resolve(true);
|
|
3076
|
-
return this.hasGrant(typeId, actions, {
|
|
3077
|
-
grantee: this.principalEntityId,
|
|
3078
|
-
prefetchedGrants,
|
|
3079
|
-
matchOwn: false,
|
|
3080
|
-
allowDefault: false,
|
|
3081
|
-
allowGroup: false,
|
|
3082
|
-
});
|
|
3083
|
-
}
|
|
3084
|
-
/**
|
|
3085
|
-
* The subject half: which records are reachable, answered with `-own`
|
|
3086
|
-
* matching and default grants both in force — the ordinary reading of a
|
|
3087
|
-
* grant, since the subject is the entity a grant is written about.
|
|
3088
|
-
*
|
|
3089
|
-
* Paired with principalAllows() so that the two halves of the
|
|
3090
|
-
* intersection are the only callers of hasGrant(): its flags differ per
|
|
3091
|
-
* side and mean nothing on their own, so no call site sets them by hand.
|
|
3092
|
-
*/
|
|
3093
|
-
subjectAllows(typeId, actions, opts = {}) {
|
|
3094
|
-
return this.hasGrant(typeId, actions, {
|
|
3095
|
-
grantee: this.subjectEntityId,
|
|
3096
|
-
record: opts.record,
|
|
3097
|
-
prefetchedGrants: opts.prefetchedGrants,
|
|
3098
|
-
groupRoles: opts.groupRoles,
|
|
3099
|
-
});
|
|
3100
|
-
}
|
|
3101
|
-
/**
|
|
3102
|
-
* How to refuse a record this request addressed by ID. A requester who
|
|
3103
|
-
* can read the record is told it exists and the verb was refused;
|
|
3104
|
-
* everyone else is told what a missing ID is told, so no one learns an ID
|
|
3105
|
-
* is live who could not have learned it by reading. `message` therefore
|
|
3106
|
-
* only ever reaches someone holding the record already.
|
|
3107
|
-
* See docs/spec/access-control.md § Errors and information exposure.
|
|
3108
|
-
*/
|
|
3109
|
-
async denialFor(record, message) {
|
|
3110
|
-
// Prefetched here rather than threaded down from the gate: a write
|
|
3111
|
-
// carried by a record-level permission settles without reading a grant
|
|
3112
|
-
// at all, and that path must not pay for this one. Both halves of
|
|
3113
|
-
// canRead share the one scan.
|
|
3114
|
-
const grants = this.principalEntityId || this.subjectEntityId
|
|
3115
|
-
? await queryAllPages((q) => this.stack.query(q), {
|
|
3116
|
-
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
3117
|
-
})
|
|
3118
|
-
: undefined;
|
|
3119
|
-
if (await this.canRead(record, grants))
|
|
3120
|
-
return new StackPermissionError(message);
|
|
3121
|
-
return new StackNotFoundError(`Record not found: "${record.id}"`);
|
|
3122
|
-
}
|
|
3123
|
-
async canRead(record, prefetchedGrants, groupRoles) {
|
|
3124
|
-
const reachable = (await this.checkRead(record)) ||
|
|
3125
|
-
(await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
|
|
3126
|
-
record,
|
|
3127
|
-
prefetchedGrants,
|
|
3128
|
-
groupRoles,
|
|
3129
|
-
}));
|
|
3130
|
-
if (!reachable)
|
|
3131
|
-
return false;
|
|
3132
|
-
return this.principalAllows(record.typeId, ['read-own', 'read-any'], prefetchedGrants);
|
|
3133
|
-
}
|
|
3134
|
-
async checkCreateGrant(typeId) {
|
|
3135
|
-
if (this.ownerActingAlone)
|
|
3136
|
-
return true;
|
|
3137
|
-
const reachable = this.subjectEntityId === this.stack.ownerEntityId ||
|
|
3138
|
-
(await this.subjectAllows(typeId, ['create']));
|
|
3139
|
-
if (!reachable)
|
|
3140
|
-
return false;
|
|
3141
|
-
return this.principalAllows(typeId, ['create']);
|
|
3142
|
-
}
|
|
3143
|
-
/**
|
|
3144
|
-
* `_group` records are managed, not merely written: only the owner or an
|
|
3145
|
-
* `admin` roster holder may mutate them — ordinary write permissions and
|
|
3146
|
-
* grants don't apply. Asked of both identities under delegation, like
|
|
3147
|
-
* a reshare. See docs/spec/identity.md § Group.
|
|
3148
|
-
*/
|
|
3149
|
-
isGroupManager(record) {
|
|
3150
|
-
if (!this.managesGroup(this.principalEntityId, record))
|
|
3151
|
-
return false;
|
|
3152
|
-
return !this.delegated || this.managesGroup(this.subjectEntityId, record);
|
|
3153
|
-
}
|
|
3154
|
-
/** Whether one identity, on its own, manages `record` — see isGroupManager(). */
|
|
3155
|
-
managesGroup(entityId, record) {
|
|
3156
|
-
if (!entityId)
|
|
3157
|
-
return false;
|
|
3158
|
-
if (entityId === this.stack.ownerEntityId)
|
|
3159
|
-
return true;
|
|
3160
|
-
return groupRoleFromAssociations(record.associations, entityId) === 'admin';
|
|
3161
|
-
}
|
|
3162
|
-
/**
|
|
3163
|
-
* Fetch a record the subject can reach and the principal holds `update` on
|
|
3164
|
-
* (via permissions or an update grant), or throw. `mutating: false` marks
|
|
3165
|
-
* the history readers, which borrow this gate without changing anything —
|
|
3166
|
-
* the one way through it a `_grant` Record stays open to.
|
|
3167
|
-
*/
|
|
3168
|
-
async requireUpdatable(id, opts = {}) {
|
|
3169
|
-
const record = await this.stack.get(id);
|
|
3170
|
-
if (!record)
|
|
3171
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3172
|
-
const mutating = opts.mutating ?? true;
|
|
3173
|
-
if (mutating)
|
|
3174
|
-
await this.requireOwnerForGrantRecord(record);
|
|
3175
|
-
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3176
|
-
if (!this.isGroupManager(record))
|
|
3177
|
-
throw await this.denialFor(record);
|
|
3178
|
-
return this.refuseIfDeleted(record, mutating);
|
|
3179
|
-
}
|
|
3180
|
-
const allowed = ((await this.checkWrite(record)) ||
|
|
3181
|
-
(await this.subjectAllows(record.typeId, ['update-own', 'update-any'], { record }))) &&
|
|
3182
|
-
(await this.principalAllows(record.typeId, ['update-own', 'update-any']));
|
|
3183
|
-
if (!allowed)
|
|
3184
|
-
throw await this.denialFor(record);
|
|
3185
|
-
return this.refuseIfDeleted(record, mutating);
|
|
3186
|
-
}
|
|
3187
|
-
/**
|
|
3188
|
-
* Refuse a mutation aimed at a soft-deleted Record. Asked only of a
|
|
3189
|
-
* mutating caller, so the history readers borrowing this gate still work,
|
|
3190
|
-
* and only after the authority decision, so "exists but deleted" is never
|
|
3191
|
-
* a probe a stranger can run. See docs/spec/versioning.md § Mutations are
|
|
3192
|
-
* refused, not applied to a tombstone.
|
|
3193
|
-
*/
|
|
3194
|
-
refuseIfDeleted(record, mutating) {
|
|
3195
|
-
if (mutating && record.deletedAt) {
|
|
3196
|
-
throw new StackConflictError(`Record "${record.id}" is soft-deleted; undelete it before mutating it.`);
|
|
3197
|
-
}
|
|
3198
|
-
return record;
|
|
3199
|
-
}
|
|
3200
|
-
/**
|
|
3201
|
-
* Fetch a record the subject can reach and the principal holds `delete` on
|
|
3202
|
-
* (via permissions or a delete grant), or throw.
|
|
3203
|
-
*/
|
|
3204
|
-
async requireDeletable(id) {
|
|
3205
|
-
const record = await this.stack.get(id);
|
|
3206
|
-
if (!record)
|
|
3207
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3208
|
-
await this.requireOwnerForGrantRecord(record);
|
|
3209
|
-
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3210
|
-
if (!this.isGroupManager(record))
|
|
3211
|
-
throw await this.denialFor(record);
|
|
3212
|
-
return record;
|
|
3213
|
-
}
|
|
3214
|
-
const allowed = ((await this.checkWrite(record)) ||
|
|
3215
|
-
(await this.subjectAllows(record.typeId, ['delete-own', 'delete-any'], { record }))) &&
|
|
3216
|
-
(await this.principalAllows(record.typeId, ['delete-own', 'delete-any']));
|
|
3217
|
-
if (!allowed)
|
|
3218
|
-
throw await this.denialFor(record);
|
|
3219
|
-
return record;
|
|
3220
|
-
}
|
|
3221
|
-
/**
|
|
3222
|
-
* Whether this request may reference `recordId` (as a parentId or
|
|
3223
|
-
* relationship target). Missing and unreadable both return false —
|
|
3224
|
-
* indistinguishable, so this can't probe for a record's existence.
|
|
3225
|
-
*/
|
|
3226
|
-
async canReadReferent(recordId) {
|
|
3227
|
-
const record = await this.stack.get(recordId);
|
|
3228
|
-
if (!record)
|
|
3229
|
-
return false;
|
|
3230
|
-
return this.canRead(record);
|
|
3231
|
-
}
|
|
3232
|
-
/**
|
|
3233
|
-
* Whether this request can read some record referencing `fileId` —
|
|
3234
|
-
* shared by canAccessFile() and the non-owner _attachment@1 create()
|
|
3235
|
-
* carve-out, which deliberately excludes the uploader clause. Walks
|
|
3236
|
-
* every referencing record, short-circuiting on the first readable one.
|
|
3237
|
-
* `_attachment@1` records are excluded from matching outright: the
|
|
3238
|
-
* carve-out must be satisfied by some *other* record referencing the
|
|
3239
|
-
* file, never by the requester's own prior metadata record for it —
|
|
3240
|
-
* allowing that would let one successful guess unlock unlimited further
|
|
3241
|
-
* metadata records for the same fileId, reintroducing the circularity
|
|
3242
|
-
* the carve-out's uploader-clause exclusion closes.
|
|
3243
|
-
* See docs/spec/attachments.md § Creating `_attachment@1` records directly.
|
|
3244
|
-
*/
|
|
3245
|
-
async hasReadableReference(fileId) {
|
|
3246
|
-
const prefetchedGrants = this.subjectEntityId
|
|
3247
|
-
? await queryAllPages((q) => this.stack.query(q), {
|
|
3248
|
-
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
3249
|
-
})
|
|
3250
|
-
: undefined;
|
|
3251
|
-
const groupRoles = new Map();
|
|
3252
|
-
const match = await findFirstMatch((q) => this.stack.query(q),
|
|
3253
|
-
// Reach, not enumeration: a record readable by ID conveys the file it
|
|
3254
|
-
// references. See docs/spec/unlisted.md.
|
|
3255
|
-
{ filter: { attachmentFileId: fileId, includeUnlisted: true } }, (record) => baseIdOf(record.typeId) !== SYSTEM_TYPES.ATTACHMENT &&
|
|
3256
|
-
this.canRead(record, prefetchedGrants, groupRoles));
|
|
3257
|
-
return match !== undefined;
|
|
3258
|
-
}
|
|
3259
|
-
/**
|
|
3260
|
-
* Whether this request may reference or download `fileId` — the dual of
|
|
3261
|
-
* getAttachment()'s access rule. Nonexistent and inaccessible are
|
|
3262
|
-
* indistinguishable (both false), so no confirmation oracle for guessed
|
|
3263
|
-
* hashes. See docs/spec/access-control.md § Reference-creation gating.
|
|
3264
|
-
*/
|
|
3265
|
-
async canAccessFile(fileId) {
|
|
3266
|
-
if (this.ownerActingAlone)
|
|
3267
|
-
return true;
|
|
3268
|
-
// Reaching a file through a record this request can read is already
|
|
3269
|
-
// fully intersected — canRead() applied the principal's mask against
|
|
3270
|
-
// that record's own type, which is the type the reference lives on.
|
|
3271
|
-
if (await this.hasReadableReference(fileId))
|
|
3272
|
-
return true;
|
|
3273
|
-
if (!this.subjectEntityId)
|
|
3274
|
-
return false;
|
|
3275
|
-
// The remaining paths are authorship facts about the subject, so they
|
|
3276
|
-
// decide *which* files match — they are not themselves a grant, and the
|
|
3277
|
-
// principal still needs one of its own on the attachment type.
|
|
3278
|
-
if (!(await this.principalAllows(`${SYSTEM_TYPES.ATTACHMENT}@1`, ['read-own', 'read-any']))) {
|
|
3279
|
-
return false;
|
|
3280
|
-
}
|
|
3281
|
-
if (this.subjectEntityId === this.stack.ownerEntityId)
|
|
3282
|
-
return true;
|
|
3283
|
-
return filtersContent(this.stack.features)
|
|
3284
|
-
? (await this.stack.query({
|
|
3285
|
-
filter: {
|
|
3286
|
-
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
|
|
3287
|
-
entityId: this.subjectEntityId,
|
|
3288
|
-
content: { fileId },
|
|
3289
|
-
},
|
|
3290
|
-
limit: 1,
|
|
3291
|
-
})).records.length > 0
|
|
3292
|
-
: (await queryAllPages((q) => this.stack.query(q), {
|
|
3293
|
-
filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, entityId: this.subjectEntityId },
|
|
3294
|
-
})).some((r) => r.content.fileId === fileId);
|
|
3295
|
-
}
|
|
3296
|
-
/** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
|
|
3297
|
-
async fileRefFieldNames(typeId) {
|
|
3298
|
-
const type = await this.stack.getType(typeId);
|
|
3299
|
-
if (!type)
|
|
3300
|
-
return [];
|
|
3301
|
-
return Object.entries(type.schema)
|
|
3302
|
-
.filter(([, def]) => def.kind === 'file-ref')
|
|
3303
|
-
.map(([field]) => field);
|
|
3304
|
-
}
|
|
3305
|
-
/**
|
|
3306
|
-
* Gates file-ref content fields on file access, mirroring the
|
|
3307
|
-
* attachment-association gate — a file-ref field conveys attachment
|
|
3308
|
-
* access exactly like an `attachment` association. Only fields present
|
|
3309
|
-
* in `content` are checked (a content patch is a merge patch; untouched fields
|
|
3310
|
-
* carry no new reference).
|
|
3311
|
-
*/
|
|
3312
|
-
async requireFileRefAccess(typeId, content) {
|
|
3313
|
-
for (const field of await this.fileRefFieldNames(typeId)) {
|
|
3314
|
-
const value = content[field];
|
|
3315
|
-
if (typeof value !== 'string')
|
|
3316
|
-
continue;
|
|
3317
|
-
if (!(await this.canAccessFile(value)))
|
|
3318
|
-
throw new StackPermissionError();
|
|
3319
|
-
}
|
|
3320
|
-
}
|
|
3321
|
-
/**
|
|
3322
|
-
* Reference-creation gate for one association: `attachment` requires
|
|
3323
|
-
* file access, and a `relationship` naming a record in this stack
|
|
3324
|
-
* requires read access to it. `tag` is unchecked, and `_group` roster
|
|
3325
|
-
* associations are gated by the stricter isGroupManager() instead.
|
|
3326
|
-
*
|
|
3327
|
-
* The other target arms are ungated because the gate's purpose —
|
|
3328
|
-
* refusing a reference that would convey access to, or confirm the
|
|
3329
|
-
* existence of, an unreadable record — has nothing to bite on: core
|
|
3330
|
-
* never resolves them, so no access flows through one.
|
|
3331
|
-
* See docs/spec/access-control.md § Reference-creation gating.
|
|
3332
|
-
*/
|
|
3333
|
-
async requireAssociationAccess(typeId, association) {
|
|
3334
|
-
if (association.kind === 'attachment') {
|
|
3335
|
-
if (!(await this.canAccessFile(association.fileId)))
|
|
3336
|
-
throw new StackPermissionError();
|
|
3337
|
-
}
|
|
3338
|
-
else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
|
|
3339
|
-
const { target } = association;
|
|
3340
|
-
// `stackUrl` is tested for a value, not for presence: absent and
|
|
3341
|
-
// empty are one target — storage, targetEqual() and the filter all
|
|
3342
|
-
// read them as this stack — so a check on presence alone would
|
|
3343
|
-
// leave one spelling of a local Record ungated.
|
|
3344
|
-
if (target.scope !== 'record' || target.stackUrl)
|
|
3345
|
-
return;
|
|
3346
|
-
if (!(await this.canReadReferent(target.recordId)))
|
|
3347
|
-
throw new StackPermissionError();
|
|
3348
|
-
}
|
|
3349
|
-
}
|
|
3350
|
-
/**
|
|
3351
|
-
* Create a record on behalf of the subject: create grant required,
|
|
3352
|
-
* anonymous denied, entityId set to the subject, client IDs skew-checked,
|
|
3353
|
-
* reference-creating options gated, and non-owner `_attachment@1`
|
|
3354
|
-
* creation refused save one carve-out. A scoped create always stamps
|
|
3355
|
-
* authorship — an absent entityId means an unscoped `Stack` wrote it.
|
|
3356
|
-
* `createdAt`/`updatedAt` are refused to everyone but the owner acting
|
|
3357
|
-
* alone — see the guard below and docs/spec/data-model.md § Record IDs.
|
|
3358
|
-
* See also docs/spec/access-control.md and docs/spec/attachments.md.
|
|
3359
|
-
*/
|
|
3360
|
-
async create(typeId, content, opts = {}) {
|
|
3361
|
-
const principal = this.principalEntityId;
|
|
3362
|
-
if (!principal)
|
|
3363
|
-
throw new StackPermissionError('Anonymous requesters cannot create records');
|
|
3364
|
-
// createdAt/updatedAt let a caller backdate a record's clock fields —
|
|
3365
|
-
// and, without `id` also supplied, its sort position too. Refused to
|
|
3366
|
-
// everyone but the owner acting alone (undelegated, authenticated as
|
|
3367
|
-
// themselves): a grantee is exactly the untrusted actor the `id`
|
|
3368
|
-
// skew check below already exists to stop from forging a sort
|
|
3369
|
-
// position, and a delegated app acting for the owner inherits none of
|
|
3370
|
-
// the owner's extra trust — same reasoning as mayGrantAccess() below.
|
|
3371
|
-
// Refused rather than silently dropped, so an app never believes it
|
|
3372
|
-
// published something it didn't — asked value-wise, since an
|
|
3373
|
-
// `undefined` carries no date and Stack.create() reads it as absent.
|
|
3374
|
-
// This is also the enforcement a server built on ScopedStack inherits
|
|
3375
|
-
// for `POST /records`: an owner-authenticated request may carry both
|
|
3376
|
-
// fields, anyone else's has them ignored.
|
|
3377
|
-
if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) && !this.ownerActingAlone) {
|
|
3378
|
-
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.');
|
|
3379
|
-
}
|
|
3380
|
-
if (!(await this.checkCreateGrant(typeId))) {
|
|
3381
|
-
throw new StackPermissionError(`No create grant for type "${typeId}"`);
|
|
3382
|
-
}
|
|
3383
|
-
// The exemption is the owner's own, so delegation doesn't carry it: an
|
|
3384
|
-
// owner principal acting for someone else would otherwise let that
|
|
3385
|
-
// subject name any fileId and reach the bytes through the uploader
|
|
3386
|
-
// clause, which matches on the subject this create stamps.
|
|
3387
|
-
if (!this.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
|
|
3388
|
-
const fileId = content.fileId;
|
|
3389
|
-
if (typeof fileId !== 'string' || !(await this.hasReadableReference(fileId))) {
|
|
3390
|
-
throw new StackPermissionError();
|
|
3391
|
-
}
|
|
3392
|
-
}
|
|
3393
|
-
if (opts.permissions?.length && !this.mayGrantAccess()) {
|
|
3394
|
-
throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
|
|
3395
|
-
}
|
|
3396
|
-
if (opts.unlisted && !this.mayGrantAccess()) {
|
|
3397
|
-
throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
|
|
3398
|
-
}
|
|
3399
|
-
this.requireOwnerForOwnerDid(typeId, content.did);
|
|
3400
|
-
await this.requireAppIdMatchesPrincipal(opts.appId);
|
|
3401
|
-
if (opts.id !== undefined) {
|
|
3402
|
-
validateRecordId(opts.id);
|
|
3403
|
-
// Skipped when createdAt is also supplied: only the owner reaches
|
|
3404
|
-
// here with that combination (checked above), and Stack.create()
|
|
3405
|
-
// below checks the id against createdAt instead of "now" — the
|
|
3406
|
-
// check here exists for a live grantee write, and a backdated
|
|
3407
|
-
// owner create is deliberately not one. See
|
|
3408
|
-
// docs/spec/data-model.md § Record IDs.
|
|
3409
|
-
if (opts.createdAt === undefined) {
|
|
3410
|
-
validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
|
|
3411
|
-
}
|
|
3412
|
-
}
|
|
3413
|
-
if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
|
|
3414
|
-
throw new StackPermissionError();
|
|
3415
|
-
}
|
|
3416
|
-
for (const assoc of opts.associations ?? []) {
|
|
3417
|
-
await this.requireAssociationAccess(typeId, assoc);
|
|
3418
|
-
}
|
|
3419
|
-
await this.requireFileRefAccess(typeId, content);
|
|
3420
|
-
return this.stack.create(typeId, content, {
|
|
3421
|
-
...opts,
|
|
3422
|
-
entityId: this.subjectEntityId ?? undefined,
|
|
3423
|
-
principalId: this.delegated ? principal : undefined,
|
|
3424
|
-
});
|
|
3425
|
-
}
|
|
3426
|
-
/**
|
|
3427
|
-
* Whether this request may decide who else reaches a record — the rule
|
|
3428
|
-
* a reshare enforces, asked at create time too so the reach it
|
|
3429
|
-
* withholds can't be taken one step earlier while authoring. A delegated
|
|
3430
|
-
* app is denied it: widening access is the one thing containment most
|
|
3431
|
-
* needs to hold. Refused rather than silently ignored, so an app never
|
|
3432
|
-
* believes it published something it didn't. Not `ownerActingAlone`:
|
|
3433
|
-
* the record is the subject's own, so an owner principal grants it no
|
|
3434
|
-
* reach the subject lacks.
|
|
3435
|
-
* See docs/spec/access-control.md § Delegation: principal and subject.
|
|
3436
|
-
*/
|
|
3437
|
-
mayGrantAccess() {
|
|
3438
|
-
return !this.delegated || this.principalEntityId === this.stack.ownerEntityId;
|
|
3439
|
-
}
|
|
3440
|
-
/**
|
|
3441
|
-
* Whether one identity, on its own, may decide who else reaches `record`
|
|
3442
|
-
* — the owner-or-creator rule a reshare enforces, asked of one
|
|
3443
|
-
* side at a time. See
|
|
3444
|
-
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
3445
|
-
*/
|
|
3446
|
-
mayReshare(entityId, record) {
|
|
3447
|
-
if (!entityId)
|
|
3448
|
-
return false;
|
|
3449
|
-
return entityId === this.stack.ownerEntityId || entityId === record.entityId;
|
|
3450
|
-
}
|
|
3451
|
-
/**
|
|
3452
|
-
* A record this request may read, or null. Never throws
|
|
3453
|
-
* StackPermissionError: an unreadable record answers exactly as a missing
|
|
3454
|
-
* one does, so a caller learns only what it may read.
|
|
3455
|
-
* See docs/spec/access-control.md § Errors and information exposure.
|
|
3456
|
-
*/
|
|
3457
|
-
async get(id, opts = {}) {
|
|
3458
|
-
const record = await this.stack.get(id, opts);
|
|
3459
|
-
if (!record)
|
|
3460
|
-
return null;
|
|
3461
|
-
if (!(await this.canRead(record)))
|
|
3462
|
-
return null;
|
|
3463
|
-
return presentDeleted(record);
|
|
3464
|
-
}
|
|
3465
|
-
/**
|
|
3466
|
-
* Runs through this.query(), so canRead() still applies — this answers a
|
|
3467
|
-
* question the caller asks on its own behalf. includeUnlisted is passed
|
|
3468
|
-
* only when the request is the owner acting alone, per
|
|
3469
|
-
* docs/spec/identity.md § DID bindings.
|
|
3470
|
-
*/
|
|
3471
|
-
async getEntityByDid(did) {
|
|
3472
|
-
return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.stack.features), this.ownerActingAlone);
|
|
3473
|
-
}
|
|
3474
|
-
async getOwnerEntity() {
|
|
3475
|
-
return this.getEntityByDid(this.stack.ownerEntityId);
|
|
3476
|
-
}
|
|
3477
|
-
/**
|
|
3478
|
-
* Query records, filtered to those this request can read. Pages are
|
|
3479
|
-
* filtered then refilled, so a page may slightly overshoot `limit` but
|
|
3480
|
-
* never skips a record. Grants are prefetched once, cursor-walked to
|
|
3481
|
-
* exhaustion.
|
|
3482
|
-
*/
|
|
3483
|
-
async query(query = {}) {
|
|
3484
|
-
assertValidSort(query.sort);
|
|
3485
|
-
assertSortCapability(query.sort, this.stack.features);
|
|
3486
|
-
assertValidRelatedTo(query.filter?.relatedTo);
|
|
3487
|
-
if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
|
|
3488
|
-
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
3489
|
-
}
|
|
3490
|
-
const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
|
|
3491
|
-
const records = [];
|
|
3492
|
-
const maxFetched = limit * 10;
|
|
3493
|
-
let totalFetched = 0;
|
|
3494
|
-
const prefetchedGrants = this.principalEntityId
|
|
3495
|
-
? await queryAllPages((q) => this.stack.query(q), {
|
|
3496
|
-
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
3497
|
-
})
|
|
3498
|
-
: undefined;
|
|
3499
|
-
// Scoped to this query, like prefetchedGrants beside it: every
|
|
3500
|
-
// candidate Record shares one roster resolution per group, and nothing
|
|
3501
|
-
// is carried into the next operation.
|
|
3502
|
-
const groupRoles = new Map();
|
|
3503
|
-
let page = { records: [], cursor: query.cursor ?? null };
|
|
3504
|
-
do {
|
|
3505
|
-
page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
|
|
3506
|
-
totalFetched += page.records.length;
|
|
3507
|
-
for (const record of page.records) {
|
|
3508
|
-
// Projected here as well as in get(), or `includeDeleted` would be
|
|
3509
|
-
// a strictly better read channel than the fetch-by-ID it is
|
|
3510
|
-
// supposed to match.
|
|
3511
|
-
if (await this.canRead(record, prefetchedGrants, groupRoles)) {
|
|
3512
|
-
records.push(presentDeleted(record));
|
|
3513
|
-
}
|
|
3514
|
-
}
|
|
3515
|
-
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
3516
|
-
return { records, cursor: page.cursor };
|
|
3517
|
-
}
|
|
3518
|
-
/**
|
|
3519
|
-
* Apply a change set on behalf of the subject. Authority is resolved
|
|
3520
|
-
* **per key** and every gate reads the record as it stands, never as the
|
|
3521
|
-
* change set would leave it: a widened `permissions` never satisfies the
|
|
3522
|
-
* read check on a `parentId` named in the same call, and a `_group`
|
|
3523
|
-
* roster never satisfies the admin check that same call has to pass.
|
|
3524
|
-
* One refused key refuses the whole call, so nothing is partially
|
|
3525
|
-
* applied and no key is silently dropped.
|
|
3526
|
-
* See docs/spec/access-control.md § Composing a change set.
|
|
3527
|
-
*/
|
|
3528
|
-
async mutate(id, changes, opts = {}) {
|
|
3529
|
-
// Ahead of every gate: a malformed change set is a validation error
|
|
3530
|
-
// for every requester, rather than one for the owner and a permission
|
|
3531
|
-
// refusal for everyone else.
|
|
3532
|
-
assertNonEmptyChangeSet(changes);
|
|
3533
|
-
if (changes.contentPatch) {
|
|
3534
|
-
const patchErrors = validatePatchValues(changes.contentPatch);
|
|
3535
|
-
if (patchErrors.length > 0)
|
|
3536
|
-
throw new StackValidationError(patchErrors);
|
|
3537
|
-
}
|
|
3538
|
-
const reshares = changes.permissions !== undefined || changes.unlisted !== undefined;
|
|
3539
|
-
const writes = changes.contentPatch !== undefined ||
|
|
3540
|
-
changes.associations !== undefined ||
|
|
3541
|
-
changes.parentId !== undefined;
|
|
3542
|
-
// requireUpdatable() reads the record and applies the write gate; the
|
|
3543
|
-
// reshare keys need the record before their own gate, and a change set
|
|
3544
|
-
// carrying only those must not be held to the write gate it doesn't
|
|
3545
|
-
// need. One read either way.
|
|
3546
|
-
const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
|
|
3547
|
-
if (writes && reshares)
|
|
3548
|
-
await this.requireReshareOf(record);
|
|
3549
|
-
if (changes.contentPatch) {
|
|
3550
|
-
const patch = changes.contentPatch;
|
|
3551
|
-
// Value-wise, not presence-wise: a client that reads a card, edits
|
|
3552
|
-
// its `name` and sends the whole content object back is not setting
|
|
3553
|
-
// the binding it round-trips, and `name` is writable by record-level
|
|
3554
|
-
// permission. Same predicate restoreVersion() applies to a snapshot.
|
|
3555
|
-
this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
|
|
3556
|
-
// Likewise value-wise: re-sending the DID a card already holds
|
|
3557
|
-
// claims nothing, and immutability refuses changing it regardless.
|
|
3558
|
-
if ('did' in patch && patch.did !== record.content.did) {
|
|
3559
|
-
this.requireOwnerForOwnerDid(record.typeId, patch.did);
|
|
3560
|
-
}
|
|
3561
|
-
await this.requireFileRefAccess(record.typeId, patch);
|
|
3562
|
-
}
|
|
3563
|
-
// Every association the change set would add is a reference this
|
|
3564
|
-
// requester has to be allowed to create — asked against the stored
|
|
3565
|
-
// set, so an association already on the record is not re-gated.
|
|
3566
|
-
for (const assoc of changes.associations ?? []) {
|
|
3567
|
-
if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
|
|
3568
|
-
continue;
|
|
3569
|
-
await this.requireAssociationAccess(record.typeId, assoc);
|
|
3570
|
-
}
|
|
3571
|
-
if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
|
|
3572
|
-
throw new StackPermissionError();
|
|
3573
|
-
}
|
|
3574
|
-
return this.stack.mutate(id, changes, { ...opts, ...this.actor });
|
|
3575
|
-
}
|
|
3576
|
-
async patchContent(id, patch, opts = {}) {
|
|
3577
|
-
return this.mutate(id, { contentPatch: patch }, opts);
|
|
3578
|
-
}
|
|
3579
|
-
/**
|
|
3580
|
-
* The record, having established that this request may decide who else
|
|
3581
|
-
* reaches it — the owner-or-creator rule, asked of both sides of a
|
|
3582
|
-
* delegation, or a Group's admin rule for a `_group`. The gate the
|
|
3583
|
-
* `permissions` and `unlisted` keys carry, which the write bit
|
|
3584
|
-
* deliberately does not confer.
|
|
3585
|
-
*/
|
|
3586
|
-
async requireReshareable(id) {
|
|
3587
|
-
const record = await this.stack.get(id);
|
|
3588
|
-
if (!record)
|
|
3589
|
-
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
3590
|
-
await this.requireOwnerForGrantRecord(record);
|
|
3591
|
-
await this.requireReshareOf(record);
|
|
3592
|
-
// Reached through its own gate rather than requireUpdatable(), so the
|
|
3593
|
-
// soft-delete refusal has to be asked here too — after the authority
|
|
3594
|
-
// decision above, for the reason refuseIfDeleted() gives.
|
|
3595
|
-
return this.refuseIfDeleted(record, true);
|
|
3596
|
-
}
|
|
3597
|
-
/** The reshare decision alone, for a record already read and write-gated. */
|
|
3598
|
-
async requireReshareOf(record) {
|
|
3599
|
-
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
3600
|
-
// Group management, not authorship: a creator later demoted from the
|
|
3601
|
-
// admin roster shouldn't retain a side door to reassign who can read
|
|
3602
|
-
// or write the group record.
|
|
3603
|
-
if (!this.isGroupManager(record))
|
|
3604
|
-
throw await this.denialFor(record);
|
|
3605
|
-
return;
|
|
3606
|
-
}
|
|
3607
|
-
// Intersected like every other authority here: the principal must hold
|
|
3608
|
-
// the verb, and the subject must be able to reach this record —
|
|
3609
|
-
// without which an owner principal would carry its subject to records
|
|
3610
|
-
// the subject cannot touch. create() withholds the same reach via
|
|
3611
|
-
// mayGrantAccess().
|
|
3612
|
-
if (!this.mayReshare(this.principalEntityId, record))
|
|
3613
|
-
throw await this.denialFor(record);
|
|
3614
|
-
if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
|
|
3615
|
-
throw await this.denialFor(record);
|
|
3616
|
-
}
|
|
3617
|
-
}
|
|
3618
|
-
/**
|
|
3619
|
-
* Naming the software behind a key is the trust decision the `_app`
|
|
3620
|
-
* registry exists to record, so both halves of that binding — `did` and
|
|
3621
|
-
* `appId` — belong to the owner alone. Same reasoning that makes `_app`
|
|
3622
|
-
* ungrantable, applied to the fields a lookup reads. Registering a card
|
|
3623
|
-
* is already owner-only; without this, record-level `write` shared on a
|
|
3624
|
-
* card would be a second way in: a card carrying no DID yet could be
|
|
3625
|
-
* pointed at a write-holder's own key while keeping the name the owner
|
|
3626
|
-
* gave it, or relabelled to claim another app's `appId`. `name` and
|
|
3627
|
-
* `version` stay writable — they are display, not lookup.
|
|
3628
|
-
*
|
|
3629
|
-
* Owner *acting alone*, in both directions: a delegated app never holds
|
|
3630
|
-
* it, and an owner principal doesn't lend it to a subject holding
|
|
3631
|
-
* record-level `write` on a card — which would reopen the same route
|
|
3632
|
-
* from the other side.
|
|
3633
|
-
*
|
|
3634
|
-
* `_entity` deliberately does not get this rule: naming people is what a
|
|
3635
|
-
* contacts app does, so its cards stay writable by grant. Uniqueness and
|
|
3636
|
-
* immutability still bind them. See docs/spec/identity.md § DID bindings.
|
|
3637
|
-
*/
|
|
3638
|
-
requireOwnerForAppIdentity(typeId, touches) {
|
|
3639
|
-
if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
|
|
3640
|
-
return;
|
|
3641
|
-
if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
|
|
3642
|
-
return;
|
|
3643
|
-
if (!this.ownerActingAlone) {
|
|
3644
|
-
throw new StackPermissionError('Only the stack owner may set an _app record’s did or appId');
|
|
3645
|
-
}
|
|
3646
|
-
}
|
|
3647
|
-
/**
|
|
3648
|
-
* A self-reported `appId` must agree with the `_app` card naming the
|
|
3649
|
-
* principal's DID, where the owner registered one. `principalId` is
|
|
3650
|
-
* verified, so letting the pair disagree would leave a verified principal
|
|
3651
|
-
* claiming a name the owner gave different software — the cross-check the
|
|
3652
|
-
* registry exists for, refused at the write instead of left to each reader.
|
|
3653
|
-
* A principal with no card keeps `appId` as the bare self-report it is for
|
|
3654
|
-
* every undelegated writer.
|
|
3655
|
-
* See docs/spec/identity.md § Attribution and what can be trusted.
|
|
3656
|
-
*/
|
|
3657
|
-
async requireAppIdMatchesPrincipal(appId) {
|
|
3658
|
-
if (appId === undefined || !this.delegated)
|
|
3659
|
-
return;
|
|
3660
|
-
const card = await findFirstMatch((q) => this.stack.query(q), {
|
|
3661
|
-
filter: {
|
|
3662
|
-
baseId: SYSTEM_TYPES.APP,
|
|
3663
|
-
includeDeleted: true,
|
|
3664
|
-
includeUnlisted: true,
|
|
3665
|
-
...(filtersContent(this.stack.features) && {
|
|
3666
|
-
content: { did: this.principalEntityId },
|
|
3667
|
-
}),
|
|
3668
|
-
},
|
|
3669
|
-
}, (r) => r.content.did === this.principalEntityId);
|
|
3670
|
-
if (card && card.content.appId !== appId) {
|
|
3671
|
-
throw new StackPermissionError(`appId "${appId}" is not the appId registered for this principal`);
|
|
3672
|
-
}
|
|
3673
|
-
}
|
|
3674
|
-
/**
|
|
3675
|
-
* The owner's own DID is the one `_entity` binding a grantee may not claim.
|
|
3676
|
-
* `ownerProfile` adopts whichever card holds it, so a card minted by
|
|
3677
|
-
* someone else becomes the stack's own profile, and uniqueness then makes
|
|
3678
|
-
* that permanent. Every other DID stays open to a contacts app, which is
|
|
3679
|
-
* the reach `_entity` is grantable for.
|
|
3680
|
-
* See docs/spec/identity.md § DID bindings.
|
|
3681
|
-
*/
|
|
3682
|
-
requireOwnerForOwnerDid(typeId, did) {
|
|
3683
|
-
if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
|
|
3684
|
-
return;
|
|
3685
|
-
if (did !== this.stack.ownerEntityId)
|
|
3686
|
-
return;
|
|
3687
|
-
if (!this.ownerActingAlone) {
|
|
3688
|
-
throw new StackPermissionError('Only the stack owner may claim the owner’s own did');
|
|
3689
|
-
}
|
|
3690
|
-
}
|
|
3691
|
-
/**
|
|
3692
|
-
* A `_grant` Record *is* authority, so rewriting one is the escalation
|
|
3693
|
-
* UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
|
|
3694
|
-
* existing grant rather than minting a fresh one. `grant()` and `revoke()`
|
|
3695
|
-
* live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
|
|
3696
|
-
* only: reading a grant Record and its history stays on the ordinary gate.
|
|
3697
|
-
* See docs/spec/access-control.md § Type-level grants.
|
|
3698
|
-
*/
|
|
3699
|
-
async requireOwnerForGrantRecord(record) {
|
|
3700
|
-
if (baseIdOf(record.typeId) !== SYSTEM_TYPES.GRANT)
|
|
3701
|
-
return;
|
|
3702
|
-
if (this.ownerActingAlone)
|
|
3703
|
-
return;
|
|
3704
|
-
throw await this.denialFor(record, 'Only the stack owner may write a _grant record');
|
|
3705
|
-
}
|
|
3706
|
-
async associate(id, association, opts = {}) {
|
|
3707
|
-
const record = await this.requireUpdatable(id);
|
|
3708
|
-
await this.requireAssociationAccess(record.typeId, association);
|
|
3709
|
-
return this.stack.associate(id, association, { ...opts, ...this.actor });
|
|
3710
|
-
}
|
|
3711
|
-
async dissociate(id, association, opts = {}) {
|
|
3712
|
-
await this.requireUpdatable(id);
|
|
3713
|
-
return this.stack.dissociate(id, association, { ...opts, ...this.actor });
|
|
3714
|
-
}
|
|
3715
|
-
/**
|
|
3716
|
-
* Hard delete is owner-only: it is irreversible and destroys version
|
|
3717
|
-
* history, so neither the write bit nor delete-own/delete-any grants
|
|
3718
|
-
* reach it, and delegation doesn't carry it either. Everyone else is
|
|
3719
|
-
* limited to soft delete.
|
|
3720
|
-
*/
|
|
3721
|
-
async delete(id, opts = {}) {
|
|
3722
|
-
await this.requireDeletable(id);
|
|
3723
|
-
if (opts.hard && !this.ownerActingAlone) {
|
|
3724
|
-
throw new StackPermissionError('Hard delete is owner-only');
|
|
3725
|
-
}
|
|
3726
|
-
return this.stack.delete(id, { ...opts, ...this.actor });
|
|
3727
|
-
}
|
|
3728
|
-
/**
|
|
3729
|
-
* Reverse a soft delete. Gated the same as delete() — undelete is the
|
|
3730
|
-
* inverse of soft delete, so granting one direction without the other
|
|
3731
|
-
* would be backwards. Idempotent, per Stack.undelete().
|
|
3732
|
-
*/
|
|
3733
|
-
async undelete(id, opts = {}) {
|
|
3734
|
-
await this.requireDeletable(id);
|
|
3735
|
-
return this.stack.undelete(id, { ...opts, ...this.actor });
|
|
3736
|
-
}
|
|
3737
|
-
/**
|
|
3738
|
-
* History is the mutation/recovery surface, not a read surface — gated
|
|
3739
|
-
* like patchContent(), with snapshot `permissions` stripped for everyone but
|
|
3740
|
-
* the owner acting alone — a snapshot's permissions are the stack's
|
|
3741
|
-
* sharing graph, which delegation is not a route to. Reading history
|
|
3742
|
-
* changes nothing, so it is the one path the `_grant` write fence leaves
|
|
3743
|
-
* alone: seeing how a Record you can already read got that way is not
|
|
3744
|
-
* the escalation that fence exists to stop, and losing it would leave a
|
|
3745
|
-
* write-holder unable to audit the Record they hold.
|
|
3746
|
-
* See docs/spec/versioning.md § History access.
|
|
3747
|
-
*/
|
|
3748
|
-
async getVersions(id) {
|
|
3749
|
-
await this.requireUpdatable(id, { mutating: false });
|
|
3750
|
-
const versions = await this.stack.getVersions(id);
|
|
3751
|
-
return this.ownerActingAlone ? versions : versions.map(stripVersionPermissions);
|
|
3752
|
-
}
|
|
3753
|
-
/** See getVersions() — same mutate-surface gate, same permissions stripping. */
|
|
3754
|
-
async getVersion(id, version) {
|
|
3755
|
-
await this.requireUpdatable(id, { mutating: false });
|
|
3756
|
-
const target = await this.stack.getVersion(id, version);
|
|
3757
|
-
if (!target)
|
|
3758
|
-
return null;
|
|
3759
|
-
return this.ownerActingAlone ? target : stripVersionPermissions(target);
|
|
3760
|
-
}
|
|
3761
|
-
/**
|
|
3762
|
-
* Re-runs the reference-creation checks against the snapshot, so a
|
|
3763
|
-
* restore can't re-convey access to a file or record the subject can no
|
|
3764
|
-
* longer reach today — the snapshot's `parentId` among them, gated
|
|
3765
|
-
* exactly as a change set's `parentId` gates a destination named directly. Only the
|
|
3766
|
-
* owner acting alone is exempt: under delegation the checks resolve
|
|
3767
|
-
* against the subject, which is whose reach the restore would widen.
|
|
3768
|
-
* See docs/spec/versioning.md § Restore semantics.
|
|
3769
|
-
*/
|
|
3770
|
-
async restoreVersion(id, version, opts = {}) {
|
|
3771
|
-
const record = await this.requireUpdatable(id);
|
|
3772
|
-
if (!this.ownerActingAlone) {
|
|
3773
|
-
const target = await this.stack.getVersion(id, version);
|
|
3774
|
-
if (target) {
|
|
3775
|
-
// A rollback that would move a card's binding is the same trust
|
|
3776
|
-
// decision a content patch reserves to the owner, reached by another route.
|
|
3777
|
-
this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
|
|
3778
|
-
record.content[field]);
|
|
3779
|
-
// Only a restore that moves the record *into* a container creates a
|
|
3780
|
-
// reference. A snapshot at the root names nothing, and a parentId
|
|
3781
|
-
// the restore would not change is not re-gated: the record is
|
|
3782
|
-
// already there, so a content rollback is not refused over a move
|
|
3783
|
-
// it isn't making.
|
|
3784
|
-
if (target.parentId !== undefined &&
|
|
3785
|
-
target.parentId !== record.parentId &&
|
|
3786
|
-
!(await this.canReadReferent(target.parentId))) {
|
|
3787
|
-
throw new StackPermissionError();
|
|
3788
|
-
}
|
|
3789
|
-
await this.requireFileRefAccess(target.typeId, target.content);
|
|
3790
|
-
// Gated against the associations the restore will actually write.
|
|
3791
|
-
// A `_group`'s don't roll back at all, so such a restore introduces
|
|
3792
|
-
// no association and there is nothing here to gate — the same
|
|
3793
|
-
// reason a `parentId` the restore would not change is not re-gated
|
|
3794
|
-
// above.
|
|
3795
|
-
if (!isGroupRecord(record)) {
|
|
3796
|
-
for (const association of target.associations ?? []) {
|
|
3797
|
-
await this.requireAssociationAccess(target.typeId, association);
|
|
3798
|
-
}
|
|
3799
|
-
}
|
|
3800
|
-
}
|
|
3801
|
-
}
|
|
3802
|
-
return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
|
|
3803
|
-
}
|
|
3804
|
-
/**
|
|
3805
|
-
* Commit a per-record migration — **the owner acting alone, only**.
|
|
3806
|
-
*
|
|
3807
|
-
* Migration is owner-driven by design: `migrateAll()`, the bulk path,
|
|
3808
|
-
* lives on `Stack` and is deliberately absent from `StackClient`, the
|
|
3809
|
-
* same way `grant()`/`revoke()` are. This is its per-record counterpart
|
|
3810
|
-
* and carries the same restriction, rather than inventing a grant model
|
|
3811
|
-
* that the bulk path deliberately doesn't have.
|
|
3812
|
-
*
|
|
3813
|
-
* The restriction is what makes the verb safe to expose at all. Migrate
|
|
3814
|
-
* replaces `content` and `typeId` wholesale, so a grant-based version
|
|
3815
|
-
* would have to re-derive every gate `create()` applies at the
|
|
3816
|
-
* destination *and* every gate `mutate()` applies over the existing
|
|
3817
|
-
* content, and would reopen each one it missed. The sharpest is the
|
|
3818
|
-
* non-owner `_attachment@1` refusal create() carries: without it, a
|
|
3819
|
-
* requester holding a create grant on `_attachment@1` and write access
|
|
3820
|
-
* to any record they authored could migrate that record into the family
|
|
3821
|
-
* naming any `fileId`, then read the bytes through canAccessFile()'s
|
|
3822
|
-
* uploader clause — the exact escalation that carve-out exists to refuse
|
|
3823
|
-
* (see docs/spec/attachments.md § Creating `_attachment@1` records
|
|
3824
|
-
* directly). Ordinary write access to a record is not consent to move it
|
|
3825
|
-
* between families.
|
|
3826
|
-
*
|
|
3827
|
-
* A server implementing `POST /records/:id/migrate` therefore serves it
|
|
3828
|
-
* to the stack owner and answers 403 otherwise. See
|
|
3829
|
-
* docs/spec/data-model.md § Type migrations.
|
|
3830
|
-
*/
|
|
3831
|
-
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
3832
|
-
if (!this.ownerActingAlone) {
|
|
3833
|
-
throw new StackPermissionError('Only the stack owner may commit a migration');
|
|
3834
|
-
}
|
|
3835
|
-
return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
|
|
3836
|
-
}
|
|
3837
|
-
/**
|
|
3838
|
-
* Store bytes and create an _attachment@1 metadata record (create grant
|
|
3839
|
-
* on `_attachment@1` required; anonymous denied), returning that record.
|
|
3840
|
-
* Authorship and principal are stamped exactly as create() does.
|
|
3841
|
-
*/
|
|
3842
|
-
async putAttachment(data, mimeType, filename, appId) {
|
|
3843
|
-
// The one ScopedStack path that reaches the adapter without going
|
|
3844
|
-
// through Stack first — without this, a closed stack would still write
|
|
3845
|
-
// bytes before the delegated create() refused.
|
|
3846
|
-
this.stack.assertOpen();
|
|
3847
|
-
const principal = this.principalEntityId;
|
|
3848
|
-
if (!principal) {
|
|
3849
|
-
throw new StackPermissionError('Anonymous requesters cannot upload attachments');
|
|
3850
|
-
}
|
|
3851
|
-
if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) {
|
|
3852
|
-
throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`);
|
|
3853
|
-
}
|
|
3854
|
-
await this.requireAppIdMatchesPrincipal(appId);
|
|
3855
|
-
assertAttachmentSize(data.byteLength, this.features.limits.attachmentBytes);
|
|
3856
|
-
const fileId = await this.adapter.putAttachment(data);
|
|
3857
|
-
return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
|
|
3858
|
-
fileId,
|
|
3859
|
-
mimeType,
|
|
3860
|
-
size: data.byteLength,
|
|
3861
|
-
...(filename && { filename }),
|
|
3862
|
-
}, {
|
|
3863
|
-
entityId: this.subjectEntityId ?? undefined,
|
|
3864
|
-
principalId: this.delegated ? principal : undefined,
|
|
3865
|
-
appId,
|
|
3866
|
-
});
|
|
3867
|
-
}
|
|
3868
|
-
/**
|
|
3869
|
-
* Download attachment bytes. Accessible to the owner, a reader of any
|
|
3870
|
-
* referencing record, or the uploader pre-association — the same
|
|
3871
|
-
* predicate as the reference-creation gate (canAccessFile).
|
|
3872
|
-
*/
|
|
3873
|
-
async getAttachment(fileId) {
|
|
3874
|
-
if (!(await this.canAccessFile(fileId)))
|
|
3875
|
-
throw new StackPermissionError();
|
|
3876
|
-
return this.stack.getAttachment(fileId);
|
|
3877
|
-
}
|
|
3878
|
-
/**
|
|
3879
|
-
* Delete an attachment. Only the stack owner may delete attachments.
|
|
3880
|
-
* Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
|
|
3881
|
-
*/
|
|
3882
|
-
async deleteAttachment(fileId) {
|
|
3883
|
-
if (!this.ownerActingAlone) {
|
|
3884
|
-
throw new StackPermissionError('Only the stack owner can delete attachments');
|
|
3885
|
-
}
|
|
3886
|
-
return this.stack.deleteAttachment(fileId, this.actor);
|
|
3887
|
-
}
|
|
3888
|
-
/**
|
|
3889
|
-
* Sweep for unreferenced attachment bytes and delete them. Only the stack
|
|
3890
|
-
* owner may run this. Delegates to Stack.collectAttachmentGarbage().
|
|
3891
|
-
*/
|
|
3892
|
-
async collectAttachmentGarbage(opts) {
|
|
3893
|
-
if (!this.ownerActingAlone) {
|
|
3894
|
-
throw new StackPermissionError('Only the stack owner can collect attachment garbage');
|
|
3895
|
-
}
|
|
3896
|
-
return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
|
|
3897
|
-
}
|
|
3898
|
-
/**
|
|
3899
|
-
* Observe the changes this request may read. The predicate is canRead
|
|
3900
|
-
* applied per event — the same one get() and query() answer with, so a
|
|
3901
|
-
* feed can't disagree with them about what this session sees.
|
|
3902
|
-
*
|
|
3903
|
-
* A record the subscriber cannot read produces no event at all, rather
|
|
3904
|
-
* than an empty or redacted one: the existence of a change is itself a
|
|
3905
|
-
* disclosure, the same reasoning that keeps a count of the whole match
|
|
3906
|
-
* off a query result. See docs/spec/events.md § Permission scoping.
|
|
3907
|
-
*/
|
|
3908
|
-
async subscribe(handler, opts = {}) {
|
|
3909
|
-
this.stack.assertOpen();
|
|
3910
|
-
if (this.stack.relaysChanges) {
|
|
3911
|
-
throw new StackRelayScopeError('This stack relays changes from elsewhere, and a scoped view cannot narrow that feed: ' +
|
|
3912
|
-
'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
|
|
3913
|
-
'no record to re-check. Subscribe with a session-scoped stack instead.');
|
|
3914
|
-
}
|
|
3915
|
-
// A relaying stack was already refused above, so relaysChanges is
|
|
3916
|
-
// always false here — `since` never has a cursor to mean anything by.
|
|
3917
|
-
assertSinceUsable(opts.since, false);
|
|
3918
|
-
if (opts.includeUnlisted && !this.ownerActingAlone) {
|
|
3919
|
-
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
3920
|
-
}
|
|
3921
|
-
return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
|
|
3922
|
-
}
|
|
3923
|
-
/**
|
|
3924
|
-
* canRead for one event, through the subscription's own cache. Grants
|
|
3925
|
-
* are prefetched once and refilled after an invalidation rather than
|
|
3926
|
-
* re-queried per event, which would be a `_grant` scan for every change
|
|
3927
|
-
* the stack makes.
|
|
3928
|
-
*/
|
|
3929
|
-
async canReadCached(record, cache) {
|
|
3930
|
-
const grants = this.principalEntityId
|
|
3931
|
-
? await cache.grants(() => queryAllPages((q) => this.stack.query(q), {
|
|
3932
|
-
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
3933
|
-
}))
|
|
3934
|
-
: undefined;
|
|
3935
|
-
return this.canRead(record, grants, cache.roles);
|
|
3936
|
-
}
|
|
3937
|
-
}
|
|
3938
1786
|
//# sourceMappingURL=stack.js.map
|