@haverstack/core 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -14
- package/dist/access.d.ts +20 -7
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +37 -16
- package/dist/access.js.map +1 -1
- package/dist/adapter-entry.d.ts +12 -0
- package/dist/adapter-entry.d.ts.map +1 -0
- package/dist/adapter-entry.js +11 -0
- package/dist/adapter-entry.js.map +1 -0
- package/dist/attachment-download.d.ts +75 -0
- package/dist/attachment-download.d.ts.map +1 -0
- package/dist/attachment-download.js +128 -0
- package/dist/attachment-download.js.map +1 -0
- package/dist/auth.d.ts +88 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +145 -0
- package/dist/auth.js.map +1 -0
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +20 -3
- package/dist/combine.js.map +1 -1
- package/dist/did-entry.d.ts +12 -0
- package/dist/did-entry.d.ts.map +1 -0
- package/dist/did-entry.js +16 -0
- package/dist/did-entry.js.map +1 -0
- package/dist/did.d.ts +51 -0
- package/dist/did.d.ts.map +1 -0
- package/dist/did.js +153 -0
- package/dist/did.js.map +1 -0
- package/dist/id.d.ts +18 -6
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +39 -10
- package/dist/id.js.map +1 -1
- package/dist/index.d.ts +15 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -9
- package/dist/index.js.map +1 -1
- package/dist/merge.d.ts +11 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +38 -0
- package/dist/merge.js.map +1 -0
- package/dist/schema.d.ts +31 -9
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +134 -11
- package/dist/schema.js.map +1 -1
- package/dist/stack.d.ts +850 -152
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +2017 -365
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +85 -23
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +261 -19
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +281 -31
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +26 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +51 -1
- package/dist/validate.js.map +1 -1
- package/dist/wire-entry.d.ts +17 -0
- package/dist/wire-entry.d.ts.map +1 -0
- package/dist/wire-entry.js +24 -0
- package/dist/wire-entry.js.map +1 -0
- package/package.json +13 -1
package/dist/stack.js
CHANGED
|
@@ -7,73 +7,442 @@
|
|
|
7
7
|
* - ID generation
|
|
8
8
|
* - Type definition and schema hashing
|
|
9
9
|
* - Content validation on write
|
|
10
|
-
* - Migration registry and
|
|
10
|
+
* - Migration registry and explicit, owner-driven migrateAll()
|
|
11
11
|
* - Version snapshotting on update
|
|
12
12
|
* - Soft and hard delete
|
|
13
13
|
*
|
|
14
14
|
* Apps should never talk to a StackAdapter directly.
|
|
15
15
|
*/
|
|
16
|
-
import { generateId } from './id.js';
|
|
17
|
-
import { hashSchema, isCompatible, parseTypeId } from './schema.js';
|
|
18
|
-
import { validateContent } from './validate.js';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
16
|
+
import { generateId, isValidIdFormat, idTimestamp } from './id.js';
|
|
17
|
+
import { hashSchema, isCompatible, parseTypeId, baseIdOf, diffSchemas, isWellFormedTypeId, } from './schema.js';
|
|
18
|
+
import { validateContent, validateReservedKeys } from './validate.js';
|
|
19
|
+
import { applyMergePatch } from './merge.js';
|
|
20
|
+
import { checkAccess, groupRoleFromAssociations } from './access.js';
|
|
21
|
+
import { firstRecordedAttachment } from './attachment-download.js';
|
|
22
|
+
import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
|
|
23
|
+
// -------------------------------------------------------
|
|
24
|
+
// Supporting types
|
|
25
|
+
// -------------------------------------------------------
|
|
26
|
+
/** Sentinel: filter.baseId resolved to zero matching types. */
|
|
27
|
+
const EMPTY_FAMILY = Symbol('empty-family');
|
|
28
|
+
/**
|
|
29
|
+
* Valid GrantAction values, for runtime validation in Stack.grant().
|
|
30
|
+
* Built from GRANT_ACTIONS (types.ts), the source of truth GrantAction is
|
|
31
|
+
* itself derived from — so this can't drift from the type.
|
|
32
|
+
*/
|
|
33
|
+
const GRANT_ACTION_SET = new Set(GRANT_ACTIONS);
|
|
34
|
+
/**
|
|
35
|
+
* System type families grant() refuses to target: a grant on any of them
|
|
36
|
+
* would let the grantee mint their own grants, touch stack config, or
|
|
37
|
+
* register an app card claiming a DID that isn't theirs — the last of
|
|
38
|
+
* which is what verified app attribution rests on. See
|
|
39
|
+
* docs/spec/access-control.md § Type-level grants.
|
|
40
|
+
*/
|
|
41
|
+
const UNGRANTABLE_SYSTEM_TYPES = new Set([
|
|
42
|
+
SYSTEM_TYPES.GRANT,
|
|
43
|
+
SYSTEM_TYPES.CONFIG,
|
|
44
|
+
SYSTEM_TYPES.APP,
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Content fields that are lookup keys rather than display values: a card
|
|
48
|
+
* claims one, and something later resolves through it. Every one of them is
|
|
49
|
+
* immutable once set. See docs/spec/identity.md § DID bindings.
|
|
50
|
+
*/
|
|
51
|
+
const BINDING_FIELDS = new Map([
|
|
52
|
+
[SYSTEM_TYPES.APP, ['did', 'appId']],
|
|
53
|
+
[SYSTEM_TYPES.ENTITY, ['did']],
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* The subset that is additionally unique per stack: the fields something
|
|
57
|
+
* resolves *by*. A Record's `principalId` finds its card by `_app.did` and
|
|
58
|
+
* its `entityId` by `_entity.did`, so a second card claiming either leaves
|
|
59
|
+
* that lookup without a single answer — and ambiguity is all an
|
|
60
|
+
* impersonating card needs.
|
|
61
|
+
*
|
|
62
|
+
* `_app.appId` is deliberately absent. Nothing resolves a card by it — the
|
|
63
|
+
* cross-check reaches the card by `did` and only compares `appId` — so
|
|
64
|
+
* uniqueness would buy no disambiguation, while forbidding the second card
|
|
65
|
+
* key rotation is supposed to produce: `appId` is required, so a
|
|
66
|
+
* replacement card for the same software necessarily repeats it. Moving one
|
|
67
|
+
* card onto another's `appId` is what immutability already refuses.
|
|
68
|
+
* See docs/spec/identity.md § DID bindings.
|
|
69
|
+
*/
|
|
70
|
+
const UNIQUE_BINDING_FIELDS = new Map([
|
|
71
|
+
[SYSTEM_TYPES.APP, ['did']],
|
|
72
|
+
[SYSTEM_TYPES.ENTITY, ['did']],
|
|
73
|
+
]);
|
|
74
|
+
const bindingFieldsOf = (family) => BINDING_FIELDS.get(family) ?? [];
|
|
75
|
+
const uniqueBindingFieldsOf = (family) => UNIQUE_BINDING_FIELDS.get(family) ?? [];
|
|
76
|
+
/**
|
|
77
|
+
* Root of the Stack error taxonomy. A single `instanceof StackError` answers
|
|
78
|
+
* "is this a Stack-domain error or a bug?" — the question a server's error
|
|
79
|
+
* middleware asks before serializing a wire body, and one a nine-arm
|
|
80
|
+
* instanceof ladder answers only by exhaustion. Every subclass carries its
|
|
81
|
+
* discriminator as an instance `code`, so serialization is a lookup rather
|
|
82
|
+
* than a chain of class tests.
|
|
83
|
+
*
|
|
84
|
+
* Membership implies a wire mapping: every code has an entry in
|
|
85
|
+
* WIRE_ERROR_STATUS. Errors with no wire representation (IdGenerationError,
|
|
86
|
+
* InvalidDidError) deliberately stay outside this hierarchy.
|
|
87
|
+
*
|
|
88
|
+
* Subclassing adds no hierarchy beyond this root — notably
|
|
89
|
+
* StackVersionConflictError is a sibling of StackConflictError, not a
|
|
90
|
+
* subtype. See docs/spec/wire-format.md § Error responses.
|
|
91
|
+
*/
|
|
92
|
+
export class StackError extends Error {
|
|
93
|
+
}
|
|
94
|
+
export class StackValidationError extends StackError {
|
|
22
95
|
errors;
|
|
96
|
+
static code = 'validation';
|
|
97
|
+
code = StackValidationError.code;
|
|
23
98
|
constructor(errors) {
|
|
24
99
|
super(`Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'));
|
|
25
100
|
this.errors = errors;
|
|
26
101
|
this.name = 'StackValidationError';
|
|
27
102
|
}
|
|
28
103
|
}
|
|
29
|
-
export class StackMigrationError extends
|
|
104
|
+
export class StackMigrationError extends StackError {
|
|
105
|
+
static code = 'migration';
|
|
106
|
+
code = StackMigrationError.code;
|
|
30
107
|
constructor(message) {
|
|
31
108
|
super(message);
|
|
32
109
|
this.name = 'StackMigrationError';
|
|
33
110
|
}
|
|
34
111
|
}
|
|
35
112
|
/** Thrown by ScopedStack when a requester lacks permission for the operation. */
|
|
36
|
-
export class StackPermissionError extends
|
|
113
|
+
export class StackPermissionError extends StackError {
|
|
114
|
+
static code = 'permission';
|
|
115
|
+
code = StackPermissionError.code;
|
|
37
116
|
constructor(message = 'Permission denied') {
|
|
38
117
|
super(message);
|
|
39
118
|
this.name = 'StackPermissionError';
|
|
40
119
|
}
|
|
41
120
|
}
|
|
42
121
|
/** Thrown when a record (or specific version) does not exist. */
|
|
43
|
-
export class StackNotFoundError extends
|
|
122
|
+
export class StackNotFoundError extends StackError {
|
|
123
|
+
static code = 'not_found';
|
|
124
|
+
code = StackNotFoundError.code;
|
|
44
125
|
constructor(message) {
|
|
45
126
|
super(message);
|
|
46
127
|
this.name = 'StackNotFoundError';
|
|
47
128
|
}
|
|
48
129
|
}
|
|
49
130
|
/** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */
|
|
50
|
-
export class StackConflictError extends
|
|
131
|
+
export class StackConflictError extends StackError {
|
|
132
|
+
static code = 'conflict';
|
|
133
|
+
code = StackConflictError.code;
|
|
51
134
|
constructor(message) {
|
|
52
135
|
super(message);
|
|
53
136
|
this.name = 'StackConflictError';
|
|
54
137
|
}
|
|
55
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Thrown when an `ifVersion` precondition doesn't match a record's current
|
|
141
|
+
* version. Deliberately not a StackConflictError subtype — the two have
|
|
142
|
+
* different recovery stories and HTTP statuses (409 vs. 412). See
|
|
143
|
+
* docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
|
|
144
|
+
*/
|
|
145
|
+
export class StackVersionConflictError extends StackError {
|
|
146
|
+
recordId;
|
|
147
|
+
expectedVersion;
|
|
148
|
+
actualVersion;
|
|
149
|
+
static code = 'version_conflict';
|
|
150
|
+
code = StackVersionConflictError.code;
|
|
151
|
+
constructor(message, recordId, expectedVersion, actualVersion) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.recordId = recordId;
|
|
154
|
+
this.expectedVersion = expectedVersion;
|
|
155
|
+
this.actualVersion = actualVersion;
|
|
156
|
+
this.name = 'StackVersionConflictError';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Thrown when a request is structurally malformed — not a content-validation
|
|
161
|
+
* failure, but input the adapter/server can't even interpret (e.g. an
|
|
162
|
+
* undecodable pagination cursor). Distinct from StackValidationError, which
|
|
163
|
+
* means the request was well-formed but content failed schema validation.
|
|
164
|
+
*/
|
|
165
|
+
export class StackQueryError extends StackError {
|
|
166
|
+
static code = 'bad_request';
|
|
167
|
+
code = StackQueryError.code;
|
|
168
|
+
constructor(message) {
|
|
169
|
+
super(message);
|
|
170
|
+
this.name = 'StackQueryError';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Fail loud rather than silently widen: a filter the adapter can't honor
|
|
175
|
+
* would otherwise be dropped, returning an unfiltered superset presented
|
|
176
|
+
* as the filtered result. Shared by Stack.query() and
|
|
177
|
+
* APIAdapter.queryRecords(). See docs/spec/data-model.md
|
|
178
|
+
* § Capability-gated filters.
|
|
179
|
+
*/
|
|
180
|
+
export function assertQueryCapabilities(filter, capabilities) {
|
|
181
|
+
if (filter?.search && !capabilities.fullTextSearch) {
|
|
182
|
+
throw new StackQueryError('Query uses filter.search, but this adapter does not declare the fullTextSearch capability.');
|
|
183
|
+
}
|
|
184
|
+
if (filter?.content && !capabilities.contentFieldQuery) {
|
|
185
|
+
throw new StackQueryError('Query uses filter.content, but this adapter does not declare the contentFieldQuery capability.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Thrown when an attachment upload exceeds the adapter's declared
|
|
190
|
+
* `maxAttachmentBytes` ceiling — checked client-side before any bytes are
|
|
191
|
+
* sent; a server still enforces 413 authoritatively regardless. See
|
|
192
|
+
* docs/spec/wire-format.md § Attachments.
|
|
193
|
+
*/
|
|
194
|
+
export class StackPayloadTooLargeError extends StackError {
|
|
195
|
+
static code = 'payload_too_large';
|
|
196
|
+
code = StackPayloadTooLargeError.code;
|
|
197
|
+
constructor(message) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = 'StackPayloadTooLargeError';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Thrown when a server abandons an operation for taking too long — in
|
|
204
|
+
* practice a full-text search, the one query whose cost the sanitizers
|
|
205
|
+
* bound the *grammar* of but not the execution of (see
|
|
206
|
+
* docs/spec/data-model.md § Capability-gated filters).
|
|
207
|
+
*
|
|
208
|
+
* Never produced in-process: both SQLite engines run synchronously, so
|
|
209
|
+
* there is nothing to interrupt from inside the call. It exists so a
|
|
210
|
+
* server that bounds query time has a class to serialize, and so the app
|
|
211
|
+
* catching it can tell "too expensive, narrow it and retry" from
|
|
212
|
+
* StackQueryError's "malformed, don't bother retrying" — the distinction
|
|
213
|
+
* that would be lost if a timeout reused `bad_request`.
|
|
214
|
+
*/
|
|
215
|
+
export class StackTimeoutError extends StackError {
|
|
216
|
+
static code = 'timeout';
|
|
217
|
+
code = StackTimeoutError.code;
|
|
218
|
+
constructor(message) {
|
|
219
|
+
super(message);
|
|
220
|
+
this.name = 'StackTimeoutError';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Shared by Stack.putAttachment() and ScopedStack.putAttachment(). */
|
|
224
|
+
function assertAttachmentSize(byteLength, maxAttachmentBytes) {
|
|
225
|
+
if (maxAttachmentBytes !== null && byteLength > maxAttachmentBytes) {
|
|
226
|
+
throw new StackPayloadTooLargeError(`Attachment (${byteLength} bytes) exceeds the ${maxAttachmentBytes}-byte limit.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The content half of the same pre-check, on Stack.create() and
|
|
231
|
+
* Stack.update(). Local adapters declare `maxContentBytes: null` and skip
|
|
232
|
+
* the serialization entirely; only a server declares a ceiling, and its
|
|
233
|
+
* own request-size limit stays authoritative — this just spares an app the
|
|
234
|
+
* round trip and gives it a typed failure instead of a 413 it has to
|
|
235
|
+
* interpret. See docs/spec/wire-format.md § Request size limits.
|
|
236
|
+
*/
|
|
237
|
+
function assertContentSize(content, maxContentBytes, what) {
|
|
238
|
+
if (maxContentBytes === null)
|
|
239
|
+
return;
|
|
240
|
+
const byteLength = new TextEncoder().encode(JSON.stringify(content)).length;
|
|
241
|
+
if (byteLength > maxContentBytes) {
|
|
242
|
+
throw new StackPayloadTooLargeError(`${what} (${byteLength} bytes) exceeds the ${maxContentBytes}-byte limit.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Thrown by defineType() when redefining an existing typeId with a schema
|
|
247
|
+
* change beyond additive evolution. The remedy is a new version, never an
|
|
248
|
+
* in-place redefinition. See docs/spec/data-model.md § Schema drift
|
|
249
|
+
* detection.
|
|
250
|
+
*/
|
|
251
|
+
export class StackSchemaDriftError extends StackError {
|
|
252
|
+
typeId;
|
|
253
|
+
violations;
|
|
254
|
+
static code = 'schema_drift';
|
|
255
|
+
code = StackSchemaDriftError.code;
|
|
256
|
+
constructor(typeId, violations) {
|
|
257
|
+
super(`Schema drift detected for type "${typeId}": the stored schema and the new definition ` +
|
|
258
|
+
`differ beyond additive evolution (new optional fields only). Bump the version instead ` +
|
|
259
|
+
`of redefining "${typeId}" in place — e.g. defineType(\`${baseIdOf(typeId)}@${(parseTypeId(typeId)?.version ?? 0) + 1}\`, ...) plus registerMigration().\n` +
|
|
260
|
+
violations.map((v) => ` ${v.path || '(root)'}: ${v.message}`).join('\n'));
|
|
261
|
+
this.typeId = typeId;
|
|
262
|
+
this.violations = violations;
|
|
263
|
+
this.name = 'StackSchemaDriftError';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Thrown when a Stack or ScopedStack is used after close(). Deliberately
|
|
268
|
+
* outside the StackError taxonomy, alongside IdGenerationError and
|
|
269
|
+
* InvalidDidError: a caller holding a closed client is a local programming
|
|
270
|
+
* error with no wire representation — no server ever responds with it.
|
|
271
|
+
* See docs/spec/adapters.md § Lifecycle.
|
|
272
|
+
*/
|
|
273
|
+
export class StackClosedError extends Error {
|
|
274
|
+
constructor(message = 'This Stack has been closed.') {
|
|
275
|
+
super(message);
|
|
276
|
+
this.name = 'StackClosedError';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// -------------------------------------------------------
|
|
280
|
+
// Record ID validation
|
|
281
|
+
// -------------------------------------------------------
|
|
282
|
+
const RESERVED_ID_PREFIX = '_';
|
|
283
|
+
const DEFAULT_ID_TIMESTAMP_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
284
|
+
/**
|
|
285
|
+
* Default grace period for Stack.collectAttachmentGarbage(), covering the
|
|
286
|
+
* upload-then-associate window. See docs/spec/attachments.md § Garbage
|
|
287
|
+
* collection.
|
|
288
|
+
*/
|
|
289
|
+
const DEFAULT_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
290
|
+
/**
|
|
291
|
+
* Format and reserved-prefix checks — full-trust context (Stack.create()).
|
|
292
|
+
* Checked before the format check: the Crockford charset already excludes
|
|
293
|
+
* "_", so a reserved-looking id (e.g. "_config") would otherwise just fail
|
|
294
|
+
* as a generic format error instead of a specific, actionable one.
|
|
295
|
+
*
|
|
296
|
+
* Throws StackQueryError, not StackValidationError: a malformed id is
|
|
297
|
+
* structurally bad input the request never gets past — it doesn't reach
|
|
298
|
+
* type-schema validation — the same reasoning that makes an undecodable
|
|
299
|
+
* pagination cursor a StackQueryError rather than a content-validation
|
|
300
|
+
* failure. See StackQueryError's doc comment.
|
|
301
|
+
*/
|
|
302
|
+
function validateRecordId(id) {
|
|
303
|
+
if (id.startsWith(RESERVED_ID_PREFIX)) {
|
|
304
|
+
throw new StackQueryError(`ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.`);
|
|
305
|
+
}
|
|
306
|
+
if (!isValidIdFormat(id)) {
|
|
307
|
+
throw new StackQueryError(`Invalid ID "${id}": expected 12 lowercase Crockford base-32 characters.`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Timestamp-prefix plausibility check for grantee-minted IDs
|
|
312
|
+
* (ScopedStack.create()) — a grantee is untrusted and could otherwise mint
|
|
313
|
+
* an ID that forges its sort position. Pass null to disable.
|
|
314
|
+
*/
|
|
315
|
+
function validateIdTimestampSkew(id, toleranceMs) {
|
|
316
|
+
if (toleranceMs === null)
|
|
317
|
+
return;
|
|
318
|
+
const skew = Math.abs(Date.now() - idTimestamp(id));
|
|
319
|
+
if (skew > toleranceMs) {
|
|
320
|
+
throw new StackValidationError([
|
|
321
|
+
{
|
|
322
|
+
path: 'id',
|
|
323
|
+
message: `ID "${id}" timestamp is outside the allowed clock-skew tolerance (${toleranceMs}ms).`,
|
|
324
|
+
},
|
|
325
|
+
]);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// -------------------------------------------------------
|
|
329
|
+
// Query helpers
|
|
330
|
+
// -------------------------------------------------------
|
|
331
|
+
/**
|
|
332
|
+
* Walks `cursor` to exhaustion for the internal call sites that need every
|
|
333
|
+
* match (grant checks, attachment cleanup) — query() itself always
|
|
334
|
+
* paginates. Throws StackQueryError past `max` rather than silently
|
|
335
|
+
* truncating a runaway scan. Not a public API.
|
|
336
|
+
*/
|
|
337
|
+
async function queryAllPages(run, query, max = QUERY_ALL_MAX) {
|
|
338
|
+
const records = [];
|
|
339
|
+
let cursor = query.cursor;
|
|
340
|
+
do {
|
|
341
|
+
const page = await run({ ...query, cursor });
|
|
342
|
+
records.push(...page.records);
|
|
343
|
+
if (records.length > max) {
|
|
344
|
+
throw new StackQueryError(`queryAllPages: exceeded max of ${max} records without exhausting the cursor`);
|
|
345
|
+
}
|
|
346
|
+
cursor = page.cursor ?? undefined;
|
|
347
|
+
} while (cursor);
|
|
348
|
+
return records;
|
|
349
|
+
}
|
|
350
|
+
/** Safety cap for queryAllPages() — generous for personal-stack scale. */
|
|
351
|
+
const QUERY_ALL_MAX = 10_000;
|
|
352
|
+
/**
|
|
353
|
+
* Cursor-walks `run(query)` looking for the first record matching
|
|
354
|
+
* `predicate`, short-circuiting on a match. Same bounded-scan discipline
|
|
355
|
+
* as queryAllPages(): a match past page one is still found, and a
|
|
356
|
+
* non-terminating scan throws StackQueryError.
|
|
357
|
+
*/
|
|
358
|
+
async function findFirstMatch(run, query, predicate, max = QUERY_ALL_MAX) {
|
|
359
|
+
let cursor = query.cursor;
|
|
360
|
+
let totalFetched = 0;
|
|
361
|
+
do {
|
|
362
|
+
const page = await run({ ...query, cursor });
|
|
363
|
+
totalFetched += page.records.length;
|
|
364
|
+
if (totalFetched > max) {
|
|
365
|
+
throw new StackQueryError(`findFirstMatch: exceeded max of ${max} records without exhausting the cursor`);
|
|
366
|
+
}
|
|
367
|
+
for (const record of page.records) {
|
|
368
|
+
if (await predicate(record))
|
|
369
|
+
return record;
|
|
370
|
+
}
|
|
371
|
+
cursor = page.cursor ?? undefined;
|
|
372
|
+
} while (cursor);
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
56
375
|
// -------------------------------------------------------
|
|
57
376
|
// Stack class
|
|
58
377
|
// -------------------------------------------------------
|
|
59
378
|
export class Stack {
|
|
60
379
|
adapter;
|
|
380
|
+
idTimestampSkewMsValue;
|
|
61
381
|
migrations = new Map();
|
|
62
|
-
|
|
382
|
+
/**
|
|
383
|
+
* Highest version this instance has defineType()'d, per baseId — what
|
|
384
|
+
* this app process understands, as distinct from what exists in shared
|
|
385
|
+
* storage. Used to detect the stale-writer case in presentAtLatest().
|
|
386
|
+
*/
|
|
387
|
+
maxDefinedVersion = new Map();
|
|
388
|
+
/**
|
|
389
|
+
* Types this instance has fetched or defined, keyed by versioned id. A
|
|
390
|
+
* Type's schema is immutable once defined, so entries are never
|
|
391
|
+
* invalidated, only added; listTypes() refreshes wholesale. See
|
|
392
|
+
* docs/spec/data-model.md § Type cache.
|
|
393
|
+
*/
|
|
394
|
+
typeCache = new Map();
|
|
395
|
+
/** Set by close(). See docs/spec/adapters.md § Lifecycle. */
|
|
396
|
+
closed = false;
|
|
397
|
+
constructor(adapter, idTimestampSkewMsValue) {
|
|
63
398
|
this.adapter = adapter;
|
|
399
|
+
this.idTimestampSkewMsValue = idTimestampSkewMsValue;
|
|
400
|
+
}
|
|
401
|
+
async getTypeCached(id) {
|
|
402
|
+
const cached = this.typeCache.get(id);
|
|
403
|
+
if (cached)
|
|
404
|
+
return cached;
|
|
405
|
+
const type = await this.adapter.getType(id);
|
|
406
|
+
if (type)
|
|
407
|
+
this.typeCache.set(id, type);
|
|
408
|
+
return type;
|
|
64
409
|
}
|
|
65
410
|
/**
|
|
66
411
|
* Create a Stack instance. Reads ownerEntityId and timezone from the adapter.
|
|
67
412
|
*/
|
|
68
|
-
static async create(adapter) {
|
|
413
|
+
static async create(adapter, opts = {}) {
|
|
69
414
|
if (!adapter.ownerEntityId) {
|
|
70
415
|
throw new Error('Stack misconfiguration: adapter has no ownerEntityId. ' +
|
|
71
416
|
'Initialise the adapter with an entityId before calling Stack.create().');
|
|
72
417
|
}
|
|
73
|
-
const stack = new Stack(adapter);
|
|
418
|
+
const stack = new Stack(adapter, opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs);
|
|
74
419
|
await stack.seedSystemTypes();
|
|
420
|
+
if (opts.ownerProfile) {
|
|
421
|
+
await stack.ensureOwnerEntity(opts.ownerProfile);
|
|
422
|
+
}
|
|
75
423
|
return stack;
|
|
76
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* Idempotent bootstrap for StackOptions.ownerProfile. Filters by family
|
|
427
|
+
* only (contentFieldQuery is capability-gated) and matches `content.did`
|
|
428
|
+
* in memory, cursor-walking so an owner card past page one isn't missed
|
|
429
|
+
* and duplicated. The probe must be blind to nothing the binding rules
|
|
430
|
+
* see, or it mints a card they then refuse and the stack won't open with
|
|
431
|
+
* `ownerProfile`: soft-deleted cards still reserve their `did`, and a card
|
|
432
|
+
* migrated to a later version still holds one.
|
|
433
|
+
* See docs/spec/identity.md § DID bindings.
|
|
434
|
+
*/
|
|
435
|
+
async ensureOwnerEntity(profile) {
|
|
436
|
+
const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`;
|
|
437
|
+
const existing = await findFirstMatch((q) => this.query(q), { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true } }, (r) => r.content.did === this.ownerEntityId);
|
|
438
|
+
if (existing)
|
|
439
|
+
return;
|
|
440
|
+
await this.create(entityTypeId, {
|
|
441
|
+
did: this.ownerEntityId,
|
|
442
|
+
name: profile.name,
|
|
443
|
+
...(profile.handle && { handle: profile.handle }),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
77
446
|
get ownerEntityId() {
|
|
78
447
|
return this.adapter.ownerEntityId;
|
|
79
448
|
}
|
|
@@ -84,33 +453,76 @@ export class Stack {
|
|
|
84
453
|
return this.adapter.capabilities;
|
|
85
454
|
}
|
|
86
455
|
/**
|
|
87
|
-
* Get a permission-scoped view of this Stack, as if
|
|
88
|
-
* the given
|
|
89
|
-
*
|
|
90
|
-
*
|
|
456
|
+
* Get a permission-scoped view of this Stack, as if requests came from
|
|
457
|
+
* the given principal (null = anonymous). Plain Stack methods are
|
|
458
|
+
* unscoped; use asEntity() when one Stack serves multiple, possibly
|
|
459
|
+
* untrusted, entities.
|
|
460
|
+
*
|
|
461
|
+
* `onBehalfOf` names the subject a delegated app acts for: authority is
|
|
462
|
+
* then the intersection of both parties' grants, while authorship and
|
|
463
|
+
* `-own` resolve against the subject. See
|
|
464
|
+
* docs/spec/access-control.md § Enforcement: Stack.asEntity().
|
|
465
|
+
*/
|
|
466
|
+
asEntity(entityId, opts = {}) {
|
|
467
|
+
this.assertOpen();
|
|
468
|
+
if (opts.onBehalfOf && !entityId) {
|
|
469
|
+
throw new StackPermissionError('An anonymous principal cannot act on behalf of an entity');
|
|
470
|
+
}
|
|
471
|
+
return new ScopedStack(this, entityId, opts.onBehalfOf ?? entityId, this.idTimestampSkewMsValue, this.adapter);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Scope to an authenticated session — what StackTokenStore.lookupToken()
|
|
475
|
+
* returns. Equivalent to asEntity(principalId, { onBehalfOf: subjectId }),
|
|
476
|
+
* and what a server should reach for at its request boundary.
|
|
91
477
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
478
|
+
* Both identities are DIDs, so passing them positionally leaves nothing
|
|
479
|
+
* to catch a swap, and a swapped pair is undetectable in the undelegated
|
|
480
|
+
* case where they are equal — it would surface only once delegation is in
|
|
481
|
+
* use, as authority no longer fenced by the app's grants and every write
|
|
482
|
+
* attributed to the app rather than the person. Taking the pair whole
|
|
483
|
+
* removes the order to get wrong. See
|
|
484
|
+
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
97
485
|
*/
|
|
98
|
-
|
|
99
|
-
return
|
|
486
|
+
forSession(session) {
|
|
487
|
+
return this.asEntity(session.principalId, { onBehalfOf: session.subjectId });
|
|
100
488
|
}
|
|
101
489
|
// -------------------------------------------------------
|
|
102
490
|
// Types
|
|
103
491
|
// -------------------------------------------------------
|
|
104
492
|
/**
|
|
105
|
-
* Define and persist a
|
|
106
|
-
*
|
|
493
|
+
* Define and persist a Type; call at app startup before creating records
|
|
494
|
+
* of the type. Redefining an existing typeId is checked against the
|
|
495
|
+
* stored schema: identical is a no-op, a name-only change persists, and
|
|
496
|
+
* anything beyond additive evolution throws StackSchemaDriftError. See
|
|
497
|
+
* docs/spec/data-model.md § Schema drift detection.
|
|
107
498
|
*/
|
|
108
499
|
async defineType(id, name, schema, opts = {}) {
|
|
500
|
+
this.assertOpen();
|
|
109
501
|
const parsed = parseTypeId(id);
|
|
110
502
|
if (!parsed) {
|
|
111
503
|
throw new Error(`Invalid TypeId format: "${id}". Expected "namespace/name@version", e.g. "com.example.myapp/note@1".`);
|
|
112
504
|
}
|
|
505
|
+
// Register this version even on the idempotent-no-op path —
|
|
506
|
+
// presentAtLatest()'s stale-writer detection depends on it.
|
|
507
|
+
const priorMax = this.maxDefinedVersion.get(parsed.baseId) ?? 0;
|
|
508
|
+
if (parsed.version > priorMax)
|
|
509
|
+
this.maxDefinedVersion.set(parsed.baseId, parsed.version);
|
|
113
510
|
const schemaHash = await hashSchema(schema);
|
|
511
|
+
const existing = await this.getTypeCached(id);
|
|
512
|
+
if (existing) {
|
|
513
|
+
if (existing.schemaHash === schemaHash) {
|
|
514
|
+
if (existing.name === name)
|
|
515
|
+
return existing;
|
|
516
|
+
// else: name-only change — falls through to the write below,
|
|
517
|
+
// schema/hash/createdAt all carried over unchanged.
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
const violations = diffSchemas(existing.schema, schema);
|
|
521
|
+
if (violations.length > 0) {
|
|
522
|
+
throw new StackSchemaDriftError(id, violations);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
114
526
|
const type = {
|
|
115
527
|
id,
|
|
116
528
|
baseId: parsed.baseId,
|
|
@@ -118,24 +530,32 @@ export class Stack {
|
|
|
118
530
|
name,
|
|
119
531
|
schema,
|
|
120
532
|
schemaHash,
|
|
121
|
-
createdAt: new Date(),
|
|
533
|
+
createdAt: existing?.createdAt ?? new Date(),
|
|
122
534
|
...(opts.migratesFrom && { migratesFrom: opts.migratesFrom }),
|
|
123
535
|
};
|
|
124
536
|
await this.adapter.saveType(type);
|
|
537
|
+
this.typeCache.set(id, type);
|
|
125
538
|
return type;
|
|
126
539
|
}
|
|
127
540
|
async getType(id) {
|
|
128
|
-
|
|
541
|
+
this.assertOpen();
|
|
542
|
+
return this.getTypeCached(id);
|
|
129
543
|
}
|
|
544
|
+
/** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */
|
|
130
545
|
async listTypes() {
|
|
131
|
-
|
|
546
|
+
this.assertOpen();
|
|
547
|
+
const types = await this.adapter.listTypes();
|
|
548
|
+
for (const type of types)
|
|
549
|
+
this.typeCache.set(type.id, type);
|
|
550
|
+
return types;
|
|
132
551
|
}
|
|
133
552
|
/**
|
|
134
553
|
* Check whether a record's type is compatible with a required schema.
|
|
135
554
|
* Useful for duck-typed consumption across types.
|
|
136
555
|
*/
|
|
137
556
|
async typeIsCompatible(typeId, requiredSchema) {
|
|
138
|
-
|
|
557
|
+
this.assertOpen();
|
|
558
|
+
const type = await this.getTypeCached(typeId);
|
|
139
559
|
if (!type)
|
|
140
560
|
return false;
|
|
141
561
|
return isCompatible(type.schema, requiredSchema);
|
|
@@ -144,14 +564,13 @@ export class Stack {
|
|
|
144
564
|
// Migration registry
|
|
145
565
|
// -------------------------------------------------------
|
|
146
566
|
/**
|
|
147
|
-
* Register a migration function between two adjacent Type versions
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* Migrations run in-memory — they do not write to the adapter unless
|
|
152
|
-
* you call migrateAll() explicitly.
|
|
567
|
+
* Register a migration function between two adjacent Type versions; call
|
|
568
|
+
* at app startup after defineType(). Runs in-memory — nothing is written
|
|
569
|
+
* until migrateAll(). Adjacent migrations are composed into chains
|
|
570
|
+
* automatically.
|
|
153
571
|
*/
|
|
154
572
|
registerMigration(migration) {
|
|
573
|
+
this.assertOpen();
|
|
155
574
|
if (this.migrations.has(migration.from)) {
|
|
156
575
|
throw new StackMigrationError(`A migration from "${migration.from}" is already registered.`);
|
|
157
576
|
}
|
|
@@ -197,17 +616,13 @@ export class Stack {
|
|
|
197
616
|
return current;
|
|
198
617
|
}
|
|
199
618
|
/**
|
|
200
|
-
* Eagerly migrate all records of a type family to the latest version
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* and written back only when next updated. Call migrateAll() when you
|
|
205
|
-
* want to commit all pending migrations in one deliberate pass, for
|
|
206
|
-
* example before a deployment or after a major schema change.
|
|
207
|
-
*
|
|
208
|
-
* Previous content is preserved in version history before each write.
|
|
619
|
+
* Eagerly migrate all records of a type family to the latest version —
|
|
620
|
+
* the only way disk state changes version. Sweeps soft-deleted records
|
|
621
|
+
* too, validates each result before writing, and aborts on the first
|
|
622
|
+
* validation failure. See docs/spec/data-model.md § Type migrations.
|
|
209
623
|
*/
|
|
210
624
|
async migrateAll(baseTypeId) {
|
|
625
|
+
this.assertOpen();
|
|
211
626
|
const types = await this.adapter.listTypes();
|
|
212
627
|
const familyTypeIds = types.filter((t) => t.baseId === baseTypeId).map((t) => t.id);
|
|
213
628
|
if (familyTypeIds.length === 0) {
|
|
@@ -221,22 +636,23 @@ export class Stack {
|
|
|
221
636
|
const migrateFn = this.resolveMigrationPath(typeId, latestId);
|
|
222
637
|
if (!migrateFn)
|
|
223
638
|
continue;
|
|
639
|
+
const latestType = await this.getTypeCached(latestId);
|
|
640
|
+
if (!latestType) {
|
|
641
|
+
throw new StackMigrationError(`migrateAll: target type "${latestId}" is not defined.`);
|
|
642
|
+
}
|
|
224
643
|
let cursor;
|
|
225
644
|
do {
|
|
226
645
|
const result = await this.adapter.queryRecords({
|
|
227
|
-
filter: { typeId },
|
|
646
|
+
filter: { typeId, includeDeleted: true },
|
|
228
647
|
limit: 100,
|
|
229
648
|
cursor,
|
|
230
649
|
});
|
|
231
650
|
for (const record of result.records) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
updatedAt: new Date(),
|
|
238
|
-
version: record.version + 1,
|
|
239
|
-
});
|
|
651
|
+
// Same checked path commitMigration() takes — a migration
|
|
652
|
+
// function is no more entitled to move a DID binding or repoint
|
|
653
|
+
// an attachment than a request body is. No ifVersion: a batch
|
|
654
|
+
// pass doesn't know each record's version going in.
|
|
655
|
+
await this.commitMigrationChecked(record, latestId, migrateFn(record.content));
|
|
240
656
|
migrated++;
|
|
241
657
|
}
|
|
242
658
|
cursor = result.cursor ?? undefined;
|
|
@@ -249,19 +665,33 @@ export class Stack {
|
|
|
249
665
|
// -------------------------------------------------------
|
|
250
666
|
/**
|
|
251
667
|
* Create a new record. Validates content against the type's schema.
|
|
668
|
+
* `_group` records get their author stamped as the first `admin` roster
|
|
669
|
+
* association here — the single stamping site for both Stack.create()
|
|
670
|
+
* and ScopedStack.create(). See docs/spec/identity.md § Group.
|
|
252
671
|
*/
|
|
253
672
|
async create(typeId, content, opts = {}) {
|
|
254
|
-
|
|
673
|
+
this.assertOpen();
|
|
674
|
+
const type = await this.getTypeCached(typeId);
|
|
255
675
|
if (!type) {
|
|
256
676
|
throw new Error(`Unknown type: "${typeId}". Call defineType() first.`);
|
|
257
677
|
}
|
|
258
|
-
const errors = validateContent(content, type.schema);
|
|
678
|
+
const errors = [...validateReservedKeys(content), ...validateContent(content, type.schema)];
|
|
259
679
|
if (errors.length > 0) {
|
|
260
680
|
throw new StackValidationError(errors);
|
|
261
681
|
}
|
|
682
|
+
assertContentSize(content, this.features.maxContentBytes, 'Content');
|
|
683
|
+
if (typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
684
|
+
await this.checkAttachmentMimeTypeOnCreate(content);
|
|
685
|
+
}
|
|
686
|
+
await this.checkBindingsOnCreate(typeId, content);
|
|
687
|
+
if (opts.id !== undefined)
|
|
688
|
+
validateRecordId(opts.id);
|
|
689
|
+
const associations = baseIdOf(typeId) === SYSTEM_TYPES.GROUP
|
|
690
|
+
? stampGroupAdmin(opts.associations, opts.entityId ?? this.ownerEntityId)
|
|
691
|
+
: opts.associations;
|
|
262
692
|
const now = new Date();
|
|
263
693
|
const record = {
|
|
264
|
-
id: generateId(),
|
|
694
|
+
id: opts.id ?? generateId(),
|
|
265
695
|
typeId,
|
|
266
696
|
createdAt: now,
|
|
267
697
|
updatedAt: now,
|
|
@@ -270,284 +700,937 @@ export class Stack {
|
|
|
270
700
|
...(opts.parentId && { parentId: opts.parentId }),
|
|
271
701
|
...(opts.entityId && { entityId: opts.entityId }),
|
|
272
702
|
...(opts.appId && { appId: opts.appId }),
|
|
703
|
+
...(opts.principalId && { principalId: opts.principalId }),
|
|
273
704
|
...(opts.permissions?.length && { permissions: opts.permissions }),
|
|
274
|
-
...(
|
|
705
|
+
...(associations?.length && { associations }),
|
|
275
706
|
};
|
|
276
707
|
return this.adapter.createRecord(record);
|
|
277
708
|
}
|
|
278
709
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* registered
|
|
283
|
-
*
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
710
|
+
* Apply the registered migration chain in memory, for presentAt:
|
|
711
|
+
* 'latest'. Never writes back. Throws StackMigrationError when the
|
|
712
|
+
* record's version can't be reconciled with what this instance has
|
|
713
|
+
* registered — the stale-writer case. See docs/spec/data-model.md
|
|
714
|
+
* § Type migrations.
|
|
715
|
+
*/
|
|
716
|
+
presentAtLatest(record) {
|
|
717
|
+
const latestId = this.latestTypeId(record.typeId);
|
|
718
|
+
if (latestId !== record.typeId) {
|
|
719
|
+
// latestTypeId() found this by walking the same migration graph
|
|
720
|
+
// resolveMigrationPath() walks, from the same starting point, so a
|
|
721
|
+
// path is always resolvable here.
|
|
722
|
+
const migrateFn = this.resolveMigrationPath(record.typeId, latestId);
|
|
723
|
+
return { ...record, typeId: latestId, content: migrateFn(record.content) };
|
|
724
|
+
}
|
|
725
|
+
const parsed = parseTypeId(record.typeId);
|
|
726
|
+
const knownMax = parsed ? this.maxDefinedVersion.get(parsed.baseId) : undefined;
|
|
727
|
+
if (parsed && knownMax !== undefined && parsed.version !== knownMax) {
|
|
728
|
+
const direction = parsed.version > knownMax
|
|
729
|
+
? `the record is newer than this app instance understands — update the app, or register the missing migrations`
|
|
730
|
+
: `no registered migration bridges the gap — call stack.registerMigration()`;
|
|
731
|
+
throw new StackMigrationError(`Record "${record.id}" is at "${record.typeId}", but this app instance has defined ` +
|
|
732
|
+
`up to "${parsed.baseId}@${knownMax}": ${direction}. Omit presentAt: "latest" to ` +
|
|
733
|
+
`read the record as stored.`);
|
|
734
|
+
}
|
|
735
|
+
return record;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Get a record by ID, exactly as stored — no implicit migration. Pass
|
|
739
|
+
* { presentAt: 'latest' } to migrate in memory; only migrateAll()
|
|
740
|
+
* commits migrations to disk.
|
|
287
741
|
*/
|
|
288
742
|
async get(id, opts = {}) {
|
|
743
|
+
this.assertOpen();
|
|
289
744
|
const record = await this.adapter.getRecord(id);
|
|
290
745
|
if (!record)
|
|
291
746
|
return null;
|
|
292
|
-
|
|
293
|
-
if (!shouldMigrate)
|
|
294
|
-
return record;
|
|
295
|
-
const latestId = this.latestTypeId(record.typeId);
|
|
296
|
-
if (latestId === record.typeId)
|
|
297
|
-
return record;
|
|
298
|
-
const migrateFn = this.resolveMigrationPath(record.typeId, latestId);
|
|
299
|
-
if (!migrateFn) {
|
|
300
|
-
console.warn(`[Stack] No migration path from "${record.typeId}" to "${latestId}" for record "${id}". ` +
|
|
301
|
-
`Returning raw record. Register migrations with stack.registerMigration().`);
|
|
302
|
-
return record;
|
|
303
|
-
}
|
|
304
|
-
return {
|
|
305
|
-
...record,
|
|
306
|
-
typeId: latestId,
|
|
307
|
-
content: migrateFn(record.content),
|
|
308
|
-
};
|
|
747
|
+
return opts.presentAt === 'latest' ? this.presentAtLatest(record) : record;
|
|
309
748
|
}
|
|
310
749
|
/**
|
|
311
|
-
* Update a record's content
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* If the record is on an older type version, its content is migrated
|
|
318
|
-
* in memory first, then the patch is applied. The updated record is
|
|
319
|
-
* written at the latest type version — this is when lazy migration
|
|
320
|
-
* is committed to disk.
|
|
321
|
-
*
|
|
322
|
-
* Validates the merged result against the type's schema. Saves the
|
|
323
|
-
* previous state to version history before updating.
|
|
324
|
-
*
|
|
325
|
-
* For association or permission changes, use associate(), dissociate(),
|
|
326
|
-
* and setPermissions() instead.
|
|
750
|
+
* Update a record's content via JSON Merge Patch: omitted fields are
|
|
751
|
+
* kept, null removes a field. Validates the merged result against the
|
|
752
|
+
* record's *current* stored type and never changes typeId (see
|
|
753
|
+
* docs/spec/data-model.md § Type migrations); snapshots the prior state
|
|
754
|
+
* to version history. Associations and permissions have their own methods.
|
|
327
755
|
*/
|
|
328
|
-
async update(id, content) {
|
|
756
|
+
async update(id, content, opts = {}) {
|
|
757
|
+
this.assertOpen();
|
|
329
758
|
const existing = await this.adapter.getRecord(id);
|
|
330
759
|
if (!existing) {
|
|
331
760
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
332
761
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// from get() gets committed to disk.
|
|
336
|
-
const latestTypeId = this.latestTypeId(existing.typeId);
|
|
337
|
-
const migrateFn = this.resolveMigrationPath(existing.typeId, latestTypeId);
|
|
338
|
-
const existingContent = migrateFn ? migrateFn(existing.content) : existing.content;
|
|
339
|
-
const type = await this.adapter.getType(latestTypeId);
|
|
762
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
763
|
+
const type = await this.getTypeCached(existing.typeId);
|
|
340
764
|
if (!type) {
|
|
341
|
-
throw new Error(`Unknown type: "${
|
|
342
|
-
}
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
else {
|
|
351
|
-
merged[key] = value;
|
|
352
|
-
}
|
|
765
|
+
throw new Error(`Unknown type: "${existing.typeId}"`);
|
|
766
|
+
}
|
|
767
|
+
// Checked on the raw patch, before the merge: a reserved key in a
|
|
768
|
+
// patch is lost by applyMergePatch rather than stored, so a check on
|
|
769
|
+
// the merged result would never see it. See validateReservedKeys().
|
|
770
|
+
const patchErrors = validateReservedKeys(content);
|
|
771
|
+
if (patchErrors.length > 0) {
|
|
772
|
+
throw new StackValidationError(patchErrors);
|
|
353
773
|
}
|
|
774
|
+
// The patch is what travels, so the patch is what's measured — a small
|
|
775
|
+
// patch against a large record is not an oversized request.
|
|
776
|
+
assertContentSize(content, this.features.maxContentBytes, 'Patch');
|
|
777
|
+
// Merge (RFC 7396 / JSON Merge Patch): null values delete a field.
|
|
778
|
+
const merged = applyMergePatch(existing.content, content);
|
|
354
779
|
const errors = validateContent(merged, type.schema);
|
|
355
780
|
if (errors.length > 0) {
|
|
356
781
|
throw new StackValidationError(errors);
|
|
357
782
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
783
|
+
if (existing.typeId === `${SYSTEM_TYPES.ATTACHMENT}@1`) {
|
|
784
|
+
this.checkAttachmentImmutableFields(content, existing.content, merged);
|
|
785
|
+
}
|
|
786
|
+
await this.checkBindingsOnUpdate(existing.typeId, id, content, existing.content, merged);
|
|
787
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
788
|
+
this.checkConfigEntityIdUnchanged(existing.content.entityId, merged.entityId);
|
|
789
|
+
}
|
|
790
|
+
return this.adapter.patchContent(id, content, {
|
|
791
|
+
expectedVersion: opts.ifVersion,
|
|
792
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
365
793
|
});
|
|
366
794
|
}
|
|
367
795
|
/**
|
|
368
|
-
* Add an association to a record.
|
|
796
|
+
* Add an association to a record. Snapshots the record's prior state and
|
|
797
|
+
* bumps version, same as update() — associations are covered by the same
|
|
798
|
+
* versioning rule as content.
|
|
369
799
|
* If the association already exists (same kind, label, and payload), this is a no-op.
|
|
370
800
|
*/
|
|
371
|
-
async associate(id, association) {
|
|
372
|
-
|
|
801
|
+
async associate(id, association, opts = {}) {
|
|
802
|
+
this.assertOpen();
|
|
803
|
+
const existing = await this.adapter.getRecord(id);
|
|
804
|
+
if (!existing) {
|
|
805
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
806
|
+
}
|
|
807
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
808
|
+
if ((existing.associations ?? []).some((a) => associationEqual(a, association)))
|
|
809
|
+
return;
|
|
810
|
+
await this.adapter.associate(id, association, {
|
|
811
|
+
expectedVersion: opts.ifVersion,
|
|
812
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
813
|
+
});
|
|
373
814
|
}
|
|
374
815
|
/**
|
|
375
|
-
* Remove an association from a record.
|
|
376
|
-
* Matched by kind, label, and payload. No-op if not found.
|
|
816
|
+
* Remove an association from a record. Snapshots and bumps version, same
|
|
817
|
+
* as associate(). Matched by kind, label, and payload. No-op if not found.
|
|
377
818
|
*/
|
|
378
|
-
async dissociate(id, association) {
|
|
379
|
-
|
|
819
|
+
async dissociate(id, association, opts = {}) {
|
|
820
|
+
this.assertOpen();
|
|
821
|
+
const existing = await this.adapter.getRecord(id);
|
|
822
|
+
if (!existing) {
|
|
823
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
824
|
+
}
|
|
825
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
826
|
+
if (!(existing.associations ?? []).some((a) => associationEqual(a, association)))
|
|
827
|
+
return;
|
|
828
|
+
await this.adapter.dissociate(id, association, {
|
|
829
|
+
expectedVersion: opts.ifVersion,
|
|
830
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
831
|
+
});
|
|
380
832
|
}
|
|
381
833
|
/**
|
|
382
|
-
* Replace all permissions on a record.
|
|
383
|
-
* Pass an empty array to make the record private (the
|
|
834
|
+
* Replace all permissions on a record. Snapshots and bumps version, same
|
|
835
|
+
* as associate(). Pass an empty array to make the record private (the
|
|
836
|
+
* default). No-op if the new set is deep-equal to the current one.
|
|
384
837
|
*/
|
|
385
|
-
async setPermissions(id, permissions) {
|
|
838
|
+
async setPermissions(id, permissions, opts = {}) {
|
|
839
|
+
this.assertOpen();
|
|
386
840
|
const existing = await this.adapter.getRecord(id);
|
|
387
841
|
if (!existing) {
|
|
388
842
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
389
843
|
}
|
|
390
|
-
|
|
844
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
845
|
+
if (permissionsEqual(existing.permissions ?? [], permissions))
|
|
846
|
+
return;
|
|
847
|
+
await this.adapter.setPermissions(id, permissions, {
|
|
848
|
+
expectedVersion: opts.ifVersion,
|
|
849
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
850
|
+
});
|
|
391
851
|
}
|
|
392
852
|
/**
|
|
393
|
-
* Soft-delete a record (default) or hard-delete it permanently
|
|
394
|
-
*
|
|
395
|
-
*
|
|
853
|
+
* Soft-delete a record (default) or hard-delete it permanently, removing
|
|
854
|
+
* the record and its version history. Soft delete snapshots and bumps
|
|
855
|
+
* version; a no-op if already deleted. `_config` is never deletable
|
|
856
|
+
* (docs/spec.md § The `_config` record). See docs/spec/versioning.md
|
|
857
|
+
* § Deletion.
|
|
396
858
|
*/
|
|
397
859
|
async delete(id, opts = {}) {
|
|
398
|
-
|
|
860
|
+
this.assertOpen();
|
|
861
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
862
|
+
throw new StackConflictError("Cannot delete the _config record: it holds the stack's identity and is required for every permission check.");
|
|
863
|
+
}
|
|
864
|
+
if (opts.hard) {
|
|
865
|
+
return this.adapter.deleteRecord(id, { hard: true, expectedVersion: opts.ifVersion });
|
|
866
|
+
}
|
|
867
|
+
const existing = await this.adapter.getRecord(id);
|
|
868
|
+
if (!existing) {
|
|
869
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
870
|
+
}
|
|
871
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
872
|
+
if (existing.deletedAt)
|
|
873
|
+
return;
|
|
874
|
+
await this.adapter.deleteRecord(id, {
|
|
875
|
+
expectedVersion: opts.ifVersion,
|
|
876
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Reverse a soft delete. Idempotent — undeleting a record that isn't
|
|
881
|
+
* deleted returns it unchanged. Hard-deleted records are gone, so this
|
|
882
|
+
* throws StackNotFoundError for them just like any other missing record.
|
|
883
|
+
* Snapshots and bumps version, same as delete().
|
|
884
|
+
*/
|
|
885
|
+
async undelete(id, opts = {}) {
|
|
886
|
+
this.assertOpen();
|
|
887
|
+
const existing = await this.adapter.getRecord(id);
|
|
888
|
+
if (!existing) {
|
|
889
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
890
|
+
}
|
|
891
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
892
|
+
if (!existing.deletedAt)
|
|
893
|
+
return existing;
|
|
894
|
+
return this.adapter.undeleteRecord(id, {
|
|
895
|
+
expectedVersion: opts.ifVersion,
|
|
896
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
897
|
+
});
|
|
399
898
|
}
|
|
400
899
|
/**
|
|
401
|
-
* Query records.
|
|
900
|
+
* Query records. filter.baseId matches every version of a type family,
|
|
901
|
+
* resolved against registered Types. Results come back exactly as
|
|
902
|
+
* stored; pass presentAt: 'latest' to migrate in memory. See
|
|
903
|
+
* docs/spec/data-model.md § Queries.
|
|
402
904
|
*/
|
|
403
905
|
async query(query = {}) {
|
|
404
|
-
|
|
405
|
-
|
|
906
|
+
this.assertOpen();
|
|
907
|
+
const { presentAt, filter, limit: rawLimit, ...rest } = query;
|
|
908
|
+
assertQueryCapabilities(filter, this.adapter.capabilities);
|
|
909
|
+
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_QUERY_LIMIT) : undefined;
|
|
910
|
+
const resolvedFilter = await this.resolveBaseIdFilter(filter);
|
|
911
|
+
if (resolvedFilter === EMPTY_FAMILY) {
|
|
912
|
+
return { records: [], cursor: null, total: 0 };
|
|
913
|
+
}
|
|
914
|
+
const result = await this.adapter.queryRecords({
|
|
915
|
+
...rest,
|
|
916
|
+
...(resolvedFilter !== undefined && { filter: resolvedFilter }),
|
|
917
|
+
...(limit !== undefined && { limit }),
|
|
918
|
+
});
|
|
919
|
+
if (presentAt !== 'latest')
|
|
920
|
+
return result;
|
|
921
|
+
return { ...result, records: result.records.map((r) => this.presentAtLatest(r)) };
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Resolve filter.baseId into a concrete typeId set (intersected with
|
|
925
|
+
* filter.typeId when both are given), so adapters never need their own
|
|
926
|
+
* baseId concept. Returns EMPTY_FAMILY when the resolved set is empty so
|
|
927
|
+
* the caller can short-circuit without an adapter round trip.
|
|
928
|
+
*/
|
|
929
|
+
async resolveBaseIdFilter(filter) {
|
|
930
|
+
if (filter?.baseId === undefined)
|
|
931
|
+
return filter;
|
|
932
|
+
const { baseId, typeId, ...rest } = filter;
|
|
933
|
+
const requestedBaseIds = Array.isArray(baseId) ? baseId : [baseId];
|
|
934
|
+
const types = await this.adapter.listTypes();
|
|
935
|
+
const familyTypeIds = types.filter((t) => requestedBaseIds.includes(t.baseId)).map((t) => t.id);
|
|
936
|
+
const resolvedTypeIds = typeId === undefined
|
|
937
|
+
? familyTypeIds
|
|
938
|
+
: familyTypeIds.filter((id) => Array.isArray(typeId) ? typeId.includes(id) : typeId === id);
|
|
939
|
+
if (resolvedTypeIds.length === 0)
|
|
940
|
+
return EMPTY_FAMILY;
|
|
941
|
+
return { ...rest, typeId: resolvedTypeIds };
|
|
406
942
|
}
|
|
407
943
|
// -------------------------------------------------------
|
|
408
944
|
// Versions
|
|
409
945
|
// -------------------------------------------------------
|
|
410
946
|
async getVersions(id) {
|
|
947
|
+
this.assertOpen();
|
|
411
948
|
return this.adapter.getVersions(id);
|
|
412
949
|
}
|
|
413
950
|
async getVersion(id, version) {
|
|
951
|
+
this.assertOpen();
|
|
414
952
|
return this.adapter.getVersion(id, version);
|
|
415
953
|
}
|
|
416
954
|
/**
|
|
417
|
-
* Restore a record to a previous version by creating a new version
|
|
418
|
-
*
|
|
955
|
+
* Restore a record to a previous version by creating a new version —
|
|
956
|
+
* never rewrites history. The snapshot is validated against its own
|
|
957
|
+
* stored typeId (not the record's current type), restores associations,
|
|
958
|
+
* and never restores permissions. See docs/spec/versioning.md § Restore
|
|
959
|
+
* semantics.
|
|
419
960
|
*/
|
|
420
|
-
async restoreVersion(id, version) {
|
|
961
|
+
async restoreVersion(id, version, opts = {}) {
|
|
962
|
+
this.assertOpen();
|
|
421
963
|
const existing = await this.adapter.getRecord(id);
|
|
422
964
|
if (!existing) {
|
|
423
965
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
424
966
|
}
|
|
967
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
425
968
|
const target = await this.adapter.getVersion(id, version);
|
|
426
969
|
if (!target) {
|
|
427
970
|
throw new StackNotFoundError(`Version ${version} not found for record "${id}"`);
|
|
428
971
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
972
|
+
const type = await this.getTypeCached(target.typeId);
|
|
973
|
+
if (!type) {
|
|
974
|
+
throw new Error(`Unknown type: "${target.typeId}"`);
|
|
975
|
+
}
|
|
976
|
+
const errors = validateContent(target.content, type.schema);
|
|
977
|
+
if (errors.length > 0) {
|
|
978
|
+
throw new StackValidationError(errors);
|
|
979
|
+
}
|
|
980
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
981
|
+
this.checkConfigEntityIdUnchanged(existing.content.entityId, target.content.entityId);
|
|
982
|
+
}
|
|
983
|
+
// Restoring is a write like any other, so it owes the same immutability
|
|
984
|
+
// check update() pays — a snapshot taken before a card claimed its DID
|
|
985
|
+
// would otherwise move the binding by rolling content back. Uniqueness
|
|
986
|
+
// needs no separate check here: a restore can only put back a value this
|
|
987
|
+
// same card already held, which immutability already refuses to change.
|
|
988
|
+
for (const field of bindingFieldsOf(baseIdOf(target.typeId))) {
|
|
989
|
+
this.checkBindingImmutable(baseIdOf(target.typeId), field, existing.content[field], target.content[field]);
|
|
990
|
+
}
|
|
991
|
+
return this.adapter.restoreVersion(id, version, {
|
|
992
|
+
expectedVersion: opts.ifVersion,
|
|
993
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
435
994
|
});
|
|
436
995
|
}
|
|
437
|
-
// -------------------------------------------------------
|
|
438
|
-
// Attachments
|
|
439
|
-
// -------------------------------------------------------
|
|
440
996
|
/**
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
997
|
+
* Commit a per-record migration: replace `content` and `typeId` together
|
|
998
|
+
* in one step, validated against `toTypeId`'s schema exactly as
|
|
999
|
+
* create()/update() validate against a type's schema. The single-record
|
|
1000
|
+
* counterpart to migrateAll() — content here is supplied by the caller
|
|
1001
|
+
* (computed client-side by the type's owning app, per
|
|
1002
|
+
* docs/spec/wire-format.md § Migration commit) rather than a registered
|
|
1003
|
+
* Migration function. Snapshots the prior state to version history, same
|
|
1004
|
+
* as update()/restoreVersion(), and takes the same optional `ifVersion`
|
|
1005
|
+
* precondition every version-bumping mutation takes — checked atomically
|
|
1006
|
+
* at the adapter, not here (see docs/spec/versioning.md § Optimistic
|
|
1007
|
+
* concurrency).
|
|
1008
|
+
*
|
|
1009
|
+
* Because `content` is a full replacement written under a new `typeId`,
|
|
1010
|
+
* this is create-shaped at the destination *and* update-shaped over the
|
|
1011
|
+
* record as it stands, so it owes both sets of integrity checks — the
|
|
1012
|
+
* binding rules, the attachment rules, and `_config`'s. Missing either
|
|
1013
|
+
* half would make migrate a second, unguarded write path to the same
|
|
1014
|
+
* state create()/update() refuse to reach.
|
|
444
1015
|
*/
|
|
445
|
-
async
|
|
446
|
-
|
|
1016
|
+
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1017
|
+
this.assertOpen();
|
|
1018
|
+
const existing = await this.adapter.getRecord(id);
|
|
1019
|
+
if (!existing) {
|
|
1020
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
1021
|
+
}
|
|
1022
|
+
this.checkIfVersion(existing, opts.ifVersion);
|
|
1023
|
+
return this.commitMigrationChecked(existing, toTypeId, content, opts.ifVersion);
|
|
447
1024
|
}
|
|
448
1025
|
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
1026
|
+
* The checked migration write, shared by commitMigration() and
|
|
1027
|
+
* migrateAll(). Takes the record already in hand rather than an id: a
|
|
1028
|
+
* batch pass holds each record from its own query page, and re-fetching
|
|
1029
|
+
* per record would cost a read apiece for nothing.
|
|
1030
|
+
*
|
|
1031
|
+
* Both callers owe the same checks. migrateAll()'s content comes from a
|
|
1032
|
+
* registered Migration function rather than a request body, but "app
|
|
1033
|
+
* code" is not a trust boundary here — the app calling commitMigration()
|
|
1034
|
+
* is the same app that registered the function, and neither may move a
|
|
1035
|
+
* DID binding or repoint an attachment. registerMigration() also places
|
|
1036
|
+
* no constraint on `from` and `to` sharing a baseId, so a migration path
|
|
1037
|
+
* can cross type families; family-crossing is exactly what the checks
|
|
1038
|
+
* below care about.
|
|
452
1039
|
*/
|
|
453
|
-
async
|
|
454
|
-
const
|
|
455
|
-
await this.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
1040
|
+
async commitMigrationChecked(existing, toTypeId, content, ifVersion) {
|
|
1041
|
+
const id = existing.id;
|
|
1042
|
+
const type = await this.getTypeCached(toTypeId);
|
|
1043
|
+
if (!type) {
|
|
1044
|
+
throw new Error(`Unknown type: "${toTypeId}". Call defineType() first.`);
|
|
1045
|
+
}
|
|
1046
|
+
const errors = [...validateReservedKeys(content), ...validateContent(content, type.schema)];
|
|
1047
|
+
if (errors.length > 0) {
|
|
1048
|
+
throw new StackValidationError(errors);
|
|
1049
|
+
}
|
|
1050
|
+
assertContentSize(content, this.features.maxContentBytes, 'Content');
|
|
1051
|
+
const fromFamily = baseIdOf(existing.typeId);
|
|
1052
|
+
const toFamily = baseIdOf(toTypeId);
|
|
1053
|
+
const existingContent = existing.content;
|
|
1054
|
+
// A `_group` record's `admin` roster entry is stamped by create(), the
|
|
1055
|
+
// single site that does it — migrate cannot, since the adapter's
|
|
1056
|
+
// commitMigration() writes `typeId` and `content` alone and leaves
|
|
1057
|
+
// associations untouched. Minting one here would produce a group with
|
|
1058
|
+
// an empty roster, manageable by nobody but the owner, so migrating
|
|
1059
|
+
// *into* the family is refused. Version-to-version stays open and
|
|
1060
|
+
// carries the existing roster with it.
|
|
1061
|
+
if (toFamily === SYSTEM_TYPES.GROUP && fromFamily !== SYSTEM_TYPES.GROUP) {
|
|
1062
|
+
throw new StackConflictError('Cannot migrate a record into _group: a group’s admin roster is stamped at creation. ' +
|
|
1063
|
+
'Create the group instead.');
|
|
1064
|
+
}
|
|
1065
|
+
if (fromFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1066
|
+
this.checkAttachmentImmutableOnMigrate(existingContent, content);
|
|
1067
|
+
}
|
|
1068
|
+
else if (toFamily === SYSTEM_TYPES.ATTACHMENT) {
|
|
1069
|
+
// A record arriving from outside the family stakes a fresh claim on
|
|
1070
|
+
// its fileId, exactly as create() does — so it owes create()'s check.
|
|
1071
|
+
await this.checkAttachmentMimeTypeOnCreate(content);
|
|
1072
|
+
}
|
|
1073
|
+
await this.checkBindingsOnMigrate(existing.typeId, toTypeId, id, existingContent, content);
|
|
1074
|
+
if (id === SYSTEM_TYPES.CONFIG) {
|
|
1075
|
+
this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
|
|
1076
|
+
}
|
|
1077
|
+
return this.adapter.commitMigration(id, toTypeId, content, {
|
|
1078
|
+
expectedVersion: ifVersion,
|
|
1079
|
+
snapshot: this.buildVersionSnapshot(existing),
|
|
460
1080
|
});
|
|
461
|
-
return fileId;
|
|
462
1081
|
}
|
|
463
|
-
|
|
464
|
-
|
|
1082
|
+
/** Uniqueness for every unique binding field a newly created card claims. */
|
|
1083
|
+
async checkBindingsOnCreate(typeId, content) {
|
|
1084
|
+
const family = baseIdOf(typeId);
|
|
1085
|
+
for (const field of uniqueBindingFieldsOf(family)) {
|
|
1086
|
+
await this.checkBindingUnique(family, field, content[field]);
|
|
1087
|
+
}
|
|
465
1088
|
}
|
|
466
1089
|
/**
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
1090
|
+
* Immutability for every binding field a patch touches, then uniqueness
|
|
1091
|
+
* for the subset that carries it. Fields absent from the patch carry no
|
|
1092
|
+
* new claim — update() is a merge, so an untouched binding is the one the
|
|
1093
|
+
* card already holds.
|
|
470
1094
|
*/
|
|
471
|
-
async
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
},
|
|
481
|
-
});
|
|
482
|
-
const metaRecords = this.features.contentFieldQuery
|
|
483
|
-
? metaResult.records
|
|
484
|
-
: metaResult.records.filter((r) => r.content.fileId === fileId);
|
|
485
|
-
if (!metaRecords.length) {
|
|
486
|
-
try {
|
|
487
|
-
await this.adapter.getAttachment(fileId);
|
|
1095
|
+
async checkBindingsOnUpdate(typeId, id, patch, existing, merged) {
|
|
1096
|
+
const family = baseIdOf(typeId);
|
|
1097
|
+
const unique = uniqueBindingFieldsOf(family);
|
|
1098
|
+
for (const field of bindingFieldsOf(family)) {
|
|
1099
|
+
if (!(field in patch))
|
|
1100
|
+
continue;
|
|
1101
|
+
this.checkBindingImmutable(family, field, existing[field], merged[field]);
|
|
1102
|
+
if (unique.includes(field)) {
|
|
1103
|
+
await this.checkBindingUnique(family, field, merged[field], id);
|
|
488
1104
|
}
|
|
489
|
-
|
|
490
|
-
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Bindings across a migration. `content` is a full replacement rather
|
|
1109
|
+
* than a patch, so there is no "field absent from the patch" case to
|
|
1110
|
+
* exempt: every binding field either keeps its value, moves to a new
|
|
1111
|
+
* one, or is shed by omission — and immutability refuses the last two
|
|
1112
|
+
* whichever family they happen in. Asked across the union of the two
|
|
1113
|
+
* families' binding fields, so a card can neither shed its DID by
|
|
1114
|
+
* migrating out of `_entity`/`_app` nor pick one up on the way in.
|
|
1115
|
+
*
|
|
1116
|
+
* Uniqueness is asked only of the destination family, which is where the
|
|
1117
|
+
* record's claim lives once the write lands, and excludes the record
|
|
1118
|
+
* itself — re-sending the value it already holds claims nothing.
|
|
1119
|
+
* See docs/spec/identity.md § DID bindings.
|
|
1120
|
+
*/
|
|
1121
|
+
async checkBindingsOnMigrate(fromTypeId, toTypeId, id, existing, content) {
|
|
1122
|
+
const fromFamily = baseIdOf(fromTypeId);
|
|
1123
|
+
const toFamily = baseIdOf(toTypeId);
|
|
1124
|
+
const checked = new Set();
|
|
1125
|
+
for (const family of fromFamily === toFamily ? [fromFamily] : [fromFamily, toFamily]) {
|
|
1126
|
+
for (const field of bindingFieldsOf(family)) {
|
|
1127
|
+
if (checked.has(field))
|
|
1128
|
+
continue;
|
|
1129
|
+
checked.add(field);
|
|
1130
|
+
this.checkBindingImmutable(family, field, existing[field], content[field]);
|
|
491
1131
|
}
|
|
492
1132
|
}
|
|
493
|
-
for (const
|
|
494
|
-
await this.
|
|
1133
|
+
for (const field of uniqueBindingFieldsOf(toFamily)) {
|
|
1134
|
+
await this.checkBindingUnique(toFamily, field, content[field], id);
|
|
495
1135
|
}
|
|
496
|
-
await this.adapter.deleteAttachment(fileId);
|
|
497
1136
|
}
|
|
498
|
-
// -------------------------------------------------------
|
|
499
|
-
// Lifecycle
|
|
500
|
-
// -------------------------------------------------------
|
|
501
1137
|
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
1138
|
+
* A unique binding field is what a lookup resolves *by* — a record's
|
|
1139
|
+
* `principalId` by `_app.did`, its `entityId` by `_entity.did`. Two cards
|
|
1140
|
+
* claiming one value would leave that lookup without a single answer, and
|
|
1141
|
+
* ambiguity is all an impersonating card needs. Enforced here rather than
|
|
1142
|
+
* by schema, since uniqueness is a property of the set, not of the value.
|
|
1143
|
+
* Called by the paths that can introduce a binding: create and update.
|
|
1144
|
+
*
|
|
1145
|
+
* Short-circuits on the first clash rather than materialising the family,
|
|
1146
|
+
* so a stack whose `_entity` family is larger than a single scan settles
|
|
1147
|
+
* the common case — the value is already taken — without walking the rest.
|
|
1148
|
+
*
|
|
1149
|
+
* Read-then-write, so two creates racing on one value can both pass.
|
|
1150
|
+
* Closing that properly means a unique index over a JSON field, which
|
|
1151
|
+
* each adapter would enforce separately — a decision about where
|
|
1152
|
+
* uniqueness lives, not a local fix.
|
|
1153
|
+
* See docs/spec/identity.md § DID bindings.
|
|
506
1154
|
*/
|
|
507
|
-
async
|
|
508
|
-
|
|
1155
|
+
async checkBindingUnique(family, field, value, excludeId) {
|
|
1156
|
+
if (typeof value !== 'string' || value === '')
|
|
1157
|
+
return;
|
|
1158
|
+
const clash = await findFirstMatch((q) => this.query(q), {
|
|
1159
|
+
filter: {
|
|
1160
|
+
baseId: family,
|
|
1161
|
+
includeDeleted: true,
|
|
1162
|
+
...(this.features.contentFieldQuery && { content: { [field]: value } }),
|
|
1163
|
+
},
|
|
1164
|
+
}, (r) => r.id !== excludeId && r.content[field] === value);
|
|
1165
|
+
if (clash) {
|
|
1166
|
+
throw new StackConflictError(`Another ${family} record already claims the ${field} "${value}"`);
|
|
1167
|
+
}
|
|
509
1168
|
}
|
|
510
1169
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
1170
|
+
* A binding is permanent once made: uniqueness stops a second card
|
|
1171
|
+
* claiming a value, but only immutability stops an existing card being
|
|
1172
|
+
* moved onto one, which reaches the same impersonation by another route.
|
|
1173
|
+
* Adopting a value is therefore a one-way step, and a subject whose key
|
|
1174
|
+
* changes gets a new card — matching identity.md's deferral of key
|
|
1175
|
+
* rotation, where a new key is a new identity rather than the same one
|
|
1176
|
+
* relabelled. See docs/spec/identity.md § DID bindings.
|
|
515
1177
|
*/
|
|
516
|
-
|
|
517
|
-
|
|
1178
|
+
checkBindingImmutable(family, field, existing, next) {
|
|
1179
|
+
if (typeof existing !== 'string' || existing === '')
|
|
1180
|
+
return;
|
|
1181
|
+
if (next === existing)
|
|
1182
|
+
return;
|
|
1183
|
+
throw new StackValidationError([
|
|
1184
|
+
{
|
|
1185
|
+
path: field,
|
|
1186
|
+
message: `${field} is immutable once set; register a new ${family} record instead`,
|
|
1187
|
+
},
|
|
1188
|
+
]);
|
|
518
1189
|
}
|
|
519
1190
|
// -------------------------------------------------------
|
|
520
|
-
//
|
|
1191
|
+
// Attachments
|
|
521
1192
|
// -------------------------------------------------------
|
|
522
1193
|
/**
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
1194
|
+
* mimeType is a property of the fileId, not the uploader's perspective:
|
|
1195
|
+
* the first metadata record for a fileId fixes it, and a conflicting
|
|
1196
|
+
* later upload is rejected. Cursor-walked so earlier records past page
|
|
1197
|
+
* one are seen. See docs/spec/attachments.md § The `_attachment` record
|
|
1198
|
+
* type.
|
|
526
1199
|
*
|
|
527
|
-
*
|
|
1200
|
+
* Best-effort by construction — check-then-create with no storage-level
|
|
1201
|
+
* uniqueness behind it, so two racing first uploads can both land on a
|
|
1202
|
+
* concurrent server. What survives that race is the *resolution*: which
|
|
1203
|
+
* record establishes the type is firstRecordedAttachment()'s total
|
|
1204
|
+
* order, the same one a server serving Content-Type applies, so both
|
|
1205
|
+
* sides name the same winner however many records exist.
|
|
528
1206
|
*/
|
|
529
|
-
async
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
1207
|
+
async checkAttachmentMimeTypeOnCreate(content) {
|
|
1208
|
+
const { fileId, mimeType } = content;
|
|
1209
|
+
if (typeof fileId !== 'string')
|
|
1210
|
+
return; // schema validation already rejected this
|
|
1211
|
+
const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
|
|
1212
|
+
const results = await queryAllPages((q) => this.query(q), {
|
|
1213
|
+
filter: {
|
|
1214
|
+
typeId: metadataTypeId,
|
|
1215
|
+
includeDeleted: true,
|
|
1216
|
+
...(this.features.contentFieldQuery && { content: { fileId } }),
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
const existing = this.features.contentFieldQuery
|
|
1220
|
+
? results
|
|
1221
|
+
: results.filter((r) => r.content.fileId === fileId);
|
|
1222
|
+
if (existing.length === 0)
|
|
1223
|
+
return;
|
|
1224
|
+
const first = firstRecordedAttachment(existing);
|
|
1225
|
+
const establishedMimeType = first.content.mimeType;
|
|
1226
|
+
if (mimeType !== establishedMimeType) {
|
|
1227
|
+
// Deliberately does not name the established mimeType — that would
|
|
1228
|
+
// confirm a guessed fileId's content type. See the anti-oracle rule
|
|
1229
|
+
// in docs/spec/attachments.md.
|
|
1230
|
+
throw new StackValidationError([
|
|
1231
|
+
{
|
|
1232
|
+
path: 'mimeType',
|
|
1233
|
+
message: 'mimeType conflicts with the mimeType already established for this fileId',
|
|
1234
|
+
},
|
|
1235
|
+
]);
|
|
533
1236
|
}
|
|
534
|
-
return records;
|
|
535
1237
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1238
|
+
/**
|
|
1239
|
+
* filename is the only mutable field on an _attachment@1 record; fileId,
|
|
1240
|
+
* size, and mimeType are immutable (even a same-value mimeType rewrite
|
|
1241
|
+
* is refused). The correction flow is delete + re-upload. See
|
|
1242
|
+
* docs/spec/attachments.md § The `_attachment` record type.
|
|
1243
|
+
*/
|
|
1244
|
+
checkAttachmentImmutableFields(patch, existing, merged) {
|
|
1245
|
+
const errors = [];
|
|
1246
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'mimeType')) {
|
|
1247
|
+
errors.push({
|
|
1248
|
+
path: 'mimeType',
|
|
1249
|
+
message: 'mimeType is immutable after creation; delete and re-upload to change it',
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'fileId') &&
|
|
1253
|
+
merged.fileId !== existing.fileId) {
|
|
1254
|
+
errors.push({ path: 'fileId', message: 'fileId is immutable' });
|
|
1255
|
+
}
|
|
1256
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'size') && merged.size !== existing.size) {
|
|
1257
|
+
errors.push({ path: 'size', message: 'size is immutable' });
|
|
1258
|
+
}
|
|
1259
|
+
if (errors.length > 0) {
|
|
1260
|
+
throw new StackValidationError(errors);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* The same immutability checkAttachmentImmutableFields() enforces, asked
|
|
1265
|
+
* value-wise instead of presence-wise: a migration replaces content
|
|
1266
|
+
* wholesale, so it necessarily re-sends `mimeType`, `fileId` and `size`
|
|
1267
|
+
* (all required) and a presence check would refuse every migration. Only
|
|
1268
|
+
* an actual change is a violation.
|
|
1269
|
+
*
|
|
1270
|
+
* Repointing `fileId` is the one that matters most: an `_attachment@1`
|
|
1271
|
+
* record naming a fileId is what canAccessFile()'s uploader clause reads,
|
|
1272
|
+
* so moving an existing record onto another file's hash is a route to
|
|
1273
|
+
* bytes the record's author never uploaded.
|
|
1274
|
+
*/
|
|
1275
|
+
checkAttachmentImmutableOnMigrate(existing, next) {
|
|
1276
|
+
const errors = [];
|
|
1277
|
+
if (next.mimeType !== existing.mimeType) {
|
|
1278
|
+
errors.push({
|
|
1279
|
+
path: 'mimeType',
|
|
1280
|
+
message: 'mimeType is immutable after creation; delete and re-upload to change it',
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
if (next.fileId !== existing.fileId) {
|
|
1284
|
+
errors.push({ path: 'fileId', message: 'fileId is immutable' });
|
|
1285
|
+
}
|
|
1286
|
+
if (next.size !== existing.size) {
|
|
1287
|
+
errors.push({ path: 'size', message: 'size is immutable' });
|
|
1288
|
+
}
|
|
1289
|
+
if (errors.length > 0) {
|
|
1290
|
+
throw new StackValidationError(errors);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* `_config.entityId` defines stack ownership; neither update() nor
|
|
1295
|
+
* restoreVersion() may change it. A conflict with stack integrity, not a
|
|
1296
|
+
* schema violation — hence StackConflictError. See docs/spec.md § The
|
|
1297
|
+
* `_config` record.
|
|
1298
|
+
*/
|
|
1299
|
+
checkConfigEntityIdUnchanged(existingEntityId, newEntityId) {
|
|
1300
|
+
if (newEntityId !== existingEntityId) {
|
|
1301
|
+
throw new StackConflictError('Cannot change _config.entityId: it defines stack ownership. ' +
|
|
1302
|
+
'Ownership transfer is not a supported operation.');
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Store bytes and create an _attachment@1 metadata record (owner-
|
|
1307
|
+
* attributed, no entityId), returning that record — `content.fileId`
|
|
1308
|
+
* addresses the bytes, `id` addresses the metadata. Delegates to the
|
|
1309
|
+
* adapter's atomic putAttachmentWithMetadata() when implemented, trusting
|
|
1310
|
+
* the returned record as backend-authoritative; otherwise falls back to
|
|
1311
|
+
* bytes-then-create(). See docs/spec/wire-format.md § Attachments.
|
|
1312
|
+
*/
|
|
1313
|
+
async putAttachment(data, mimeType, filename, appId) {
|
|
1314
|
+
this.assertOpen();
|
|
1315
|
+
assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
|
|
1316
|
+
if (this.adapter.putAttachmentWithMetadata) {
|
|
1317
|
+
const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
|
|
1318
|
+
return record;
|
|
1319
|
+
}
|
|
1320
|
+
const fileId = await this.adapter.putAttachment(data);
|
|
1321
|
+
return this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
|
|
1322
|
+
fileId,
|
|
1323
|
+
mimeType,
|
|
1324
|
+
size: data.byteLength,
|
|
1325
|
+
...(filename && { filename }),
|
|
1326
|
+
}, { appId });
|
|
1327
|
+
}
|
|
1328
|
+
async getAttachment(fileId) {
|
|
1329
|
+
this.assertOpen();
|
|
1330
|
+
return this.adapter.getAttachment(fileId);
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Delete an attachment's bytes and its _attachment@1 metadata record(s).
|
|
1334
|
+
* Throws StackConflictError if any record in the stack still references the file.
|
|
1335
|
+
* Throws StackNotFoundError if neither metadata records nor bytes exist.
|
|
1336
|
+
*/
|
|
1337
|
+
async deleteAttachment(fileId) {
|
|
1338
|
+
this.assertOpen();
|
|
1339
|
+
const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
|
|
1340
|
+
const deletedRecordIds = this.adapter.deleteUnreferencedAttachmentRecords
|
|
1341
|
+
? await this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId)
|
|
1342
|
+
: await this.deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId);
|
|
1343
|
+
if (!deletedRecordIds.length) {
|
|
1344
|
+
try {
|
|
1345
|
+
await this.adapter.getAttachment(fileId);
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
await this.adapter.deleteAttachment(fileId);
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Non-atomic fallback for adapters that don't implement
|
|
1355
|
+
* deleteUnreferencedAttachmentRecords(): a concurrent associate() can
|
|
1356
|
+
* race between the reference check below and the deletes it guards.
|
|
1357
|
+
*/
|
|
1358
|
+
async deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId) {
|
|
1359
|
+
// A soft-deleted record still counts as a reference — it must find its
|
|
1360
|
+
// attachments intact on undelete. See docs/spec/attachments.md
|
|
1361
|
+
// § Deleting attachments.
|
|
1362
|
+
const refResult = await this.query({
|
|
1363
|
+
filter: { attachmentFileId: fileId, includeDeleted: true },
|
|
1364
|
+
limit: 1,
|
|
1365
|
+
});
|
|
1366
|
+
if (refResult.records.length > 0) {
|
|
1367
|
+
throw new StackConflictError('Attachment is still referenced by one or more records');
|
|
1368
|
+
}
|
|
1369
|
+
// Cursor-walk with includeDeleted: metadata past page one, or soft-
|
|
1370
|
+
// deleted, must be cleaned up too — not left pointing at deleted bytes.
|
|
1371
|
+
const metaResults = await queryAllPages((q) => this.query(q), {
|
|
1372
|
+
filter: {
|
|
1373
|
+
typeId: metadataTypeId,
|
|
1374
|
+
includeDeleted: true,
|
|
1375
|
+
...(this.features.contentFieldQuery && { content: { fileId } }),
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
const metaRecords = this.features.contentFieldQuery
|
|
1379
|
+
? metaResults
|
|
1380
|
+
: metaResults.filter((r) => r.content.fileId === fileId);
|
|
1381
|
+
for (const record of metaRecords) {
|
|
1382
|
+
await this.delete(record.id, { hard: true });
|
|
1383
|
+
}
|
|
1384
|
+
return metaRecords.map((r) => r.id);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Sweep for attachment bytes unreachable from any record — live or
|
|
1388
|
+
* soft-deleted — and delete bytes + metadata. Deletion goes through
|
|
1389
|
+
* deleteAttachment(), so a file re-referenced by sweep time is skipped,
|
|
1390
|
+
* not a failure. See docs/spec/attachments.md § Garbage collection.
|
|
1391
|
+
*/
|
|
1392
|
+
async collectAttachmentGarbage(opts = {}) {
|
|
1393
|
+
this.assertOpen();
|
|
1394
|
+
const graceMs = opts.graceMs ?? DEFAULT_GC_GRACE_MS;
|
|
1395
|
+
const dryRun = opts.dryRun ?? false;
|
|
1396
|
+
const now = Date.now();
|
|
1397
|
+
const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
|
|
1398
|
+
const metaRecords = await queryAllPages((q) => this.query(q), {
|
|
1399
|
+
filter: { typeId: metadataTypeId, includeDeleted: true },
|
|
1400
|
+
});
|
|
1401
|
+
// Newest metadata record's createdAt per fileId, and its size (constant
|
|
1402
|
+
// across records sharing a fileId, since content-addressing guarantees
|
|
1403
|
+
// identical bytes) — used for the grace check and reclaimedBytes.
|
|
1404
|
+
const metaByFile = new Map();
|
|
1405
|
+
for (const record of metaRecords) {
|
|
1406
|
+
const content = record.content;
|
|
1407
|
+
const createdAt = record.createdAt.getTime();
|
|
1408
|
+
const existing = metaByFile.get(content.fileId);
|
|
1409
|
+
if (!existing || createdAt > existing.newestAt) {
|
|
1410
|
+
metaByFile.set(content.fileId, { newestAt: createdAt, size: content.size });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
// Bare-bytes orphans: blobs with zero metadata records, only
|
|
1414
|
+
// discoverable if the blob adapter implements listFiles().
|
|
1415
|
+
const blobByFile = new Map();
|
|
1416
|
+
if (this.adapter.listFiles) {
|
|
1417
|
+
for (const file of await this.adapter.listFiles()) {
|
|
1418
|
+
blobByFile.set(file.fileId, { modifiedAt: file.modifiedAt.getTime(), size: file.size });
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
const candidateFileIds = new Set([...metaByFile.keys(), ...blobByFile.keys()]);
|
|
1422
|
+
const deleted = [];
|
|
1423
|
+
let reclaimedBytes = 0;
|
|
1424
|
+
for (const fileId of candidateFileIds) {
|
|
1425
|
+
const refResult = await this.query({
|
|
1426
|
+
filter: { attachmentFileId: fileId, includeDeleted: true },
|
|
1427
|
+
limit: 1,
|
|
1428
|
+
});
|
|
1429
|
+
if (refResult.records.length > 0)
|
|
1430
|
+
continue;
|
|
1431
|
+
const meta = metaByFile.get(fileId);
|
|
1432
|
+
const blob = blobByFile.get(fileId);
|
|
1433
|
+
const newestAt = meta?.newestAt ?? blob?.modifiedAt;
|
|
1434
|
+
if (newestAt !== undefined && now - newestAt < graceMs)
|
|
1435
|
+
continue;
|
|
1436
|
+
const size = meta?.size ?? blob?.size ?? 0;
|
|
1437
|
+
if (dryRun) {
|
|
1438
|
+
deleted.push(fileId);
|
|
1439
|
+
reclaimedBytes += size;
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
try {
|
|
1443
|
+
await this.deleteAttachment(fileId);
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
// Raced with a new reference, or another sweep/call already removed
|
|
1447
|
+
// it — not a sweep failure, just move on to the next candidate.
|
|
1448
|
+
if (err instanceof StackConflictError || err instanceof StackNotFoundError)
|
|
1449
|
+
continue;
|
|
1450
|
+
throw err;
|
|
1451
|
+
}
|
|
1452
|
+
deleted.push(fileId);
|
|
1453
|
+
reclaimedBytes += size;
|
|
1454
|
+
}
|
|
1455
|
+
return { deleted, reclaimedBytes };
|
|
1456
|
+
}
|
|
1457
|
+
// -------------------------------------------------------
|
|
1458
|
+
// Lifecycle
|
|
1459
|
+
// -------------------------------------------------------
|
|
1460
|
+
/**
|
|
1461
|
+
* Flush pending writes to the underlying storage. A no-op for adapters
|
|
1462
|
+
* that commit on every call (SQLite, the API adapter); meaningful for
|
|
1463
|
+
* ones that buffer, and for checkpointing a stack that stays open —
|
|
1464
|
+
* close() covers the teardown case on its own.
|
|
1465
|
+
*/
|
|
1466
|
+
async flush() {
|
|
1467
|
+
this.assertOpen();
|
|
1468
|
+
await this.adapter.flush?.();
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Flush, then release any resources the adapter holds (connections, file
|
|
1472
|
+
* handles, lock files). A failed flush still releases them before it
|
|
1473
|
+
* propagates: an unwritable stack must not also leak a lock file.
|
|
1474
|
+
* See docs/spec/adapters.md § Lifecycle.
|
|
1475
|
+
*/
|
|
1476
|
+
async close() {
|
|
1477
|
+
if (this.closed)
|
|
1478
|
+
return;
|
|
1479
|
+
// Marked closed up front so a failed flush can't leave the stack
|
|
1480
|
+
// half-open and invite a second close() onto an already-closed adapter.
|
|
1481
|
+
// Flushes through the adapter directly, past the now-tripped guard.
|
|
1482
|
+
this.closed = true;
|
|
1483
|
+
try {
|
|
1484
|
+
await this.adapter.flush?.();
|
|
1485
|
+
}
|
|
1486
|
+
finally {
|
|
1487
|
+
await this.adapter.close?.();
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Throws once close() has run. Public only so ScopedStack can gate the
|
|
1492
|
+
* one path it takes to the adapter directly; not an app-facing API.
|
|
1493
|
+
*/
|
|
1494
|
+
assertOpen() {
|
|
1495
|
+
if (this.closed)
|
|
1496
|
+
throw new StackClosedError();
|
|
1497
|
+
}
|
|
1498
|
+
// -------------------------------------------------------
|
|
1499
|
+
// Grants
|
|
1500
|
+
// -------------------------------------------------------
|
|
1501
|
+
/**
|
|
1502
|
+
* Create _grant records authorizing entities to act on records of
|
|
1503
|
+
* specific types; null entityId writes a default grant (any
|
|
1504
|
+
* authenticated entity).
|
|
1505
|
+
*
|
|
1506
|
+
* Granting an **app** a `-own` action does not contain it the way the
|
|
1507
|
+
* suffix suggests: when that app acts for someone, `-own` is read as the
|
|
1508
|
+
* bare verb and the subject decides which records are in reach, so in a
|
|
1509
|
+
* personal stack — where nearly every record is owner-authored — a
|
|
1510
|
+
* delegated `read-own` is close to `read-any`. Grant an app the types it
|
|
1511
|
+
* needs, not the suffix that looks narrowest. See
|
|
1512
|
+
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1513
|
+
*
|
|
1514
|
+
* The grantee lives in content.granteeEntityId,
|
|
1515
|
+
* not record.entityId. See docs/spec/access-control.md § Type-level
|
|
1516
|
+
* grants.
|
|
1517
|
+
*/
|
|
1518
|
+
async grant(entityId, grants) {
|
|
1519
|
+
this.assertOpen();
|
|
1520
|
+
this.checkGrantsValid(grants);
|
|
1521
|
+
const records = [];
|
|
1522
|
+
for (const g of grants) {
|
|
1523
|
+
records.push(await this.create(`${SYSTEM_TYPES.GRANT}@1`, {
|
|
1524
|
+
typeId: g.typeId,
|
|
1525
|
+
actions: g.actions,
|
|
1526
|
+
...(entityId && { granteeEntityId: entityId }),
|
|
1527
|
+
}));
|
|
1528
|
+
}
|
|
1529
|
+
return records;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* List _grant records. Omit `entityId` for all grants; pass null for
|
|
1533
|
+
* only default grants; pass a specific entityId for the grants that
|
|
1534
|
+
* currently apply to that entity (ones naming them, plus every default
|
|
1535
|
+
* grant) — the same resolution hasGrant() uses.
|
|
1536
|
+
*/
|
|
1537
|
+
async listGrants(entityId) {
|
|
1538
|
+
this.assertOpen();
|
|
1539
|
+
const all = await queryAllPages((q) => this.query(q), {
|
|
1540
|
+
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
1541
|
+
});
|
|
1542
|
+
if (entityId === undefined)
|
|
1543
|
+
return all;
|
|
1544
|
+
return all.filter((r) => {
|
|
1545
|
+
const granteeEntityId = r.content.granteeEntityId;
|
|
1546
|
+
return entityId === null
|
|
1547
|
+
? !granteeEntityId
|
|
1548
|
+
: !granteeEntityId || granteeEntityId === entityId;
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* The inverse of grant(): soft-deletes _grant records matching `entityId`
|
|
1553
|
+
* (null for default grants) and each `{ typeId, actions }` pair, at the
|
|
1554
|
+
* same granularity grant() writes. A soft delete like any other — the
|
|
1555
|
+
* owner can undelete a revocation.
|
|
1556
|
+
*/
|
|
1557
|
+
async revoke(entityId, grants) {
|
|
1558
|
+
this.assertOpen();
|
|
1559
|
+
const all = await queryAllPages((q) => this.query(q), {
|
|
1560
|
+
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
1561
|
+
});
|
|
1562
|
+
for (const g of grants) {
|
|
1563
|
+
const familyId = baseIdOf(g.typeId);
|
|
1564
|
+
const actionSet = new Set(g.actions);
|
|
1565
|
+
const matches = all.filter((r) => {
|
|
1566
|
+
const c = r.content;
|
|
1567
|
+
if (baseIdOf(c.typeId) !== familyId)
|
|
1568
|
+
return false;
|
|
1569
|
+
if ((c.granteeEntityId ?? null) !== entityId)
|
|
1570
|
+
return false;
|
|
1571
|
+
return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a));
|
|
1572
|
+
});
|
|
1573
|
+
for (const match of matches) {
|
|
1574
|
+
await this.delete(match.id);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
// -------------------------------------------------------
|
|
1579
|
+
// Private helpers
|
|
1580
|
+
// -------------------------------------------------------
|
|
1581
|
+
/**
|
|
1582
|
+
* Validates the whole grant batch before any record is created, so a bad
|
|
1583
|
+
* entry fails clean rather than leaving a partial set written. Actions
|
|
1584
|
+
* must be known GrantAction values, typeIds must be well-formed, and the
|
|
1585
|
+
* _grant/_config families are refused — see docs/spec/access-control.md
|
|
1586
|
+
* § Type-level grants.
|
|
1587
|
+
*/
|
|
1588
|
+
checkGrantsValid(grants) {
|
|
1589
|
+
const errors = [];
|
|
1590
|
+
grants.forEach((g, i) => {
|
|
1591
|
+
g.actions.forEach((action, j) => {
|
|
1592
|
+
if (!GRANT_ACTION_SET.has(action)) {
|
|
1593
|
+
errors.push({
|
|
1594
|
+
path: `grants[${i}].actions[${j}]`,
|
|
1595
|
+
message: `Unknown grant action "${action}"`,
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
if (!isWellFormedTypeId(g.typeId)) {
|
|
1600
|
+
errors.push({
|
|
1601
|
+
path: `grants[${i}].typeId`,
|
|
1602
|
+
message: `"${g.typeId}" is not a well-formed baseId or versioned TypeId (expected "baseId" or "baseId@version")`,
|
|
1603
|
+
});
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (UNGRANTABLE_SYSTEM_TYPES.has(baseIdOf(g.typeId))) {
|
|
1607
|
+
const refused = [...UNGRANTABLE_SYSTEM_TYPES].join(', ');
|
|
1608
|
+
errors.push({
|
|
1609
|
+
path: `grants[${i}].typeId`,
|
|
1610
|
+
message: `Cannot grant on "${baseIdOf(g.typeId)}": grants on ${refused} are refused to prevent privilege escalation`,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
if (errors.length > 0) {
|
|
1615
|
+
throw new StackValidationError(errors);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
async seedSystemTypes() {
|
|
540
1619
|
await this.defineType(`${SYSTEM_TYPES.CONFIG}@1`, 'Config', {
|
|
541
1620
|
entityId: { kind: 'string', required: true },
|
|
542
|
-
|
|
1621
|
+
// Optional passthrough app metadata — see ConfigContent.timezone.
|
|
1622
|
+
timezone: { kind: 'string' },
|
|
543
1623
|
});
|
|
544
1624
|
await this.defineType(`${SYSTEM_TYPES.ENTITY}@1`, 'Entity', {
|
|
1625
|
+
did: { kind: 'string', required: true },
|
|
545
1626
|
name: { kind: 'string', required: true },
|
|
546
1627
|
handle: { kind: 'string' },
|
|
547
1628
|
});
|
|
548
1629
|
await this.defineType(`${SYSTEM_TYPES.APP}@1`, 'App', {
|
|
1630
|
+
appId: { kind: 'string', required: true },
|
|
549
1631
|
name: { kind: 'string', required: true },
|
|
550
1632
|
version: { kind: 'string' },
|
|
1633
|
+
did: { kind: 'string' },
|
|
551
1634
|
});
|
|
552
1635
|
await this.defineType(`${SYSTEM_TYPES.GROUP}@1`, 'Group', {
|
|
553
1636
|
name: { kind: 'string', required: true },
|
|
@@ -557,6 +1640,7 @@ export class Stack {
|
|
|
557
1640
|
await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', {
|
|
558
1641
|
typeId: { kind: 'string', required: true },
|
|
559
1642
|
actions: { kind: 'array', items: { kind: 'string' }, required: true },
|
|
1643
|
+
granteeEntityId: { kind: 'string' },
|
|
560
1644
|
});
|
|
561
1645
|
await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', {
|
|
562
1646
|
fileId: { kind: 'string', required: true },
|
|
@@ -565,131 +1649,514 @@ export class Stack {
|
|
|
565
1649
|
filename: { kind: 'string' },
|
|
566
1650
|
});
|
|
567
1651
|
}
|
|
568
|
-
|
|
569
|
-
|
|
1652
|
+
/**
|
|
1653
|
+
* Fast-fail for the ifVersion precondition using the already-fetched
|
|
1654
|
+
* record. The adapter re-checks atomically at write time (the source of
|
|
1655
|
+
* truth for concurrent writers) — this just skips validation and
|
|
1656
|
+
* snapshotting work when the mismatch is already visible.
|
|
1657
|
+
*/
|
|
1658
|
+
checkIfVersion(existing, ifVersion) {
|
|
1659
|
+
if (ifVersion === undefined || existing.version === ifVersion)
|
|
1660
|
+
return;
|
|
1661
|
+
throw new StackVersionConflictError(`Record "${existing.id}" is at version ${existing.version}, expected ${ifVersion}`, existing.id, ifVersion, existing.version);
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Snapshot of a record's prior state, passed with the mutating adapter
|
|
1665
|
+
* call so snapshot and mutation land in one atomic write. `associations`
|
|
1666
|
+
* is always present ([] when empty) so restore can distinguish "cleared"
|
|
1667
|
+
* from a snapshot that omits the key entirely ("leave as-is"). See
|
|
1668
|
+
* docs/spec/versioning.md § Version history.
|
|
1669
|
+
*/
|
|
1670
|
+
buildVersionSnapshot(record) {
|
|
1671
|
+
return {
|
|
570
1672
|
version: record.version,
|
|
1673
|
+
typeId: record.typeId,
|
|
571
1674
|
content: record.content,
|
|
572
1675
|
updatedAt: record.updatedAt,
|
|
573
1676
|
...(record.entityId && { entityId: record.entityId }),
|
|
1677
|
+
associations: record.associations ?? [],
|
|
1678
|
+
...(record.permissions && { permissions: record.permissions }),
|
|
574
1679
|
};
|
|
575
|
-
await this.adapter.saveVersion(record.id, version);
|
|
576
1680
|
}
|
|
577
1681
|
}
|
|
578
1682
|
// -------------------------------------------------------
|
|
1683
|
+
// Equality helpers
|
|
1684
|
+
// -------------------------------------------------------
|
|
1685
|
+
/**
|
|
1686
|
+
* Matches the SQLite adapter's association primary key (kind, label,
|
|
1687
|
+
* file_id, related_id).
|
|
1688
|
+
*/
|
|
1689
|
+
function associationEqual(a, b) {
|
|
1690
|
+
if (a.kind !== b.kind || a.label !== b.label)
|
|
1691
|
+
return false;
|
|
1692
|
+
if (a.kind === 'attachment' && b.kind === 'attachment')
|
|
1693
|
+
return a.fileId === b.fileId;
|
|
1694
|
+
if (a.kind === 'relationship' && b.kind === 'relationship')
|
|
1695
|
+
return a.recordId === b.recordId;
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1698
|
+
function permissionEqual(a, b) {
|
|
1699
|
+
if (a.access !== b.access)
|
|
1700
|
+
return false;
|
|
1701
|
+
if (a.access === 'public')
|
|
1702
|
+
return true;
|
|
1703
|
+
if (a.access === 'entity' && b.access === 'entity') {
|
|
1704
|
+
return a.entityId === b.entityId && a.read === b.read && a.write === b.write;
|
|
1705
|
+
}
|
|
1706
|
+
if (a.access === 'group' && b.access === 'group') {
|
|
1707
|
+
return a.groupId === b.groupId && a.role === b.role && a.read === b.read && a.write === b.write;
|
|
1708
|
+
}
|
|
1709
|
+
return false;
|
|
1710
|
+
}
|
|
1711
|
+
function permissionsEqual(a, b) {
|
|
1712
|
+
return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
|
|
1713
|
+
}
|
|
1714
|
+
// -------------------------------------------------------
|
|
579
1715
|
// ScopedStack
|
|
580
1716
|
// -------------------------------------------------------
|
|
581
1717
|
/** Default page size used to fill a permission-filtered query result. */
|
|
582
1718
|
const DEFAULT_QUERY_LIMIT = 50;
|
|
583
1719
|
const MAX_QUERY_LIMIT = 1000;
|
|
584
1720
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
1721
|
+
* Ensures `creator` carries an `admin` relationship association, adding one
|
|
1722
|
+
* if it's not already present. Used to bootstrap a `_group` record's first
|
|
1723
|
+
* admin at create time.
|
|
1724
|
+
*/
|
|
1725
|
+
function stampGroupAdmin(associations, creator) {
|
|
1726
|
+
const list = associations ?? [];
|
|
1727
|
+
const alreadyAdmin = list.some((a) => a.kind === 'relationship' && a.label === 'admin' && a.recordId === creator);
|
|
1728
|
+
if (alreadyAdmin)
|
|
1729
|
+
return list;
|
|
1730
|
+
return [...list, { kind: 'relationship', label: 'admin', recordId: creator }];
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Drops a snapshot's `permissions` — owner-only audit data, never served
|
|
1734
|
+
* to a non-owner history reader. `entityId` (change attribution) stays.
|
|
1735
|
+
* See docs/spec/versioning.md § History access.
|
|
1736
|
+
*/
|
|
1737
|
+
function stripVersionPermissions(version) {
|
|
1738
|
+
if (version.permissions === undefined)
|
|
1739
|
+
return version;
|
|
1740
|
+
const { permissions: _permissions, ...rest } = version;
|
|
1741
|
+
return rest;
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* A permission-enforcing view of a Stack for a single (principal, subject)
|
|
1745
|
+
* pair, obtained
|
|
1746
|
+
* via `stack.asEntity(entityId)`. Missing records throw StackNotFoundError
|
|
1747
|
+
* (or return null on reads); existing-but-inaccessible ones throw
|
|
1748
|
+
* StackPermissionError. See docs/spec/access-control.md.
|
|
1749
|
+
*
|
|
1750
|
+
* Two identities, one rule: **the principal governs authority, the subject
|
|
1751
|
+
* governs attribution.** Grant lookup and the privilege-bearing gates that
|
|
1752
|
+
* no grant reaches (setPermissions, group management, hard delete, widening
|
|
1753
|
+
* access at create time) key on `principalEntityId`; authorship, `-own`
|
|
1754
|
+
* matching, record-level permission resolution, and "files I uploaded"
|
|
1755
|
+
* lookups key on `subjectEntityId`.
|
|
587
1756
|
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
1757
|
+
* Unconditional owner access follows that same split rather than one
|
|
1758
|
+
* identity: it answers *what data is reachable* for the subject (an owner
|
|
1759
|
+
* subject resolves past every permission check) and *who may exercise a
|
|
1760
|
+
* privileged verb* for the principal (an owner app is not bounded by
|
|
1761
|
+
* grants). Under delegation both halves apply, and a mistake on the
|
|
1762
|
+
* authority side is an escalation rather than a preference.
|
|
593
1763
|
*/
|
|
594
1764
|
export class ScopedStack {
|
|
595
1765
|
stack;
|
|
596
|
-
|
|
597
|
-
|
|
1766
|
+
principalEntityId;
|
|
1767
|
+
subjectEntityId;
|
|
1768
|
+
idTimestampSkewMs;
|
|
1769
|
+
adapter;
|
|
1770
|
+
constructor(stack, principalEntityId, subjectEntityId, idTimestampSkewMs,
|
|
1771
|
+
// Bytes-storage primitive for putAttachment(). Held directly because
|
|
1772
|
+
// ScopedStack always composes bytes + its own create() — the record
|
|
1773
|
+
// must carry the subject's entityId and the principal behind it,
|
|
1774
|
+
// neither of which the adapter-level atomic capability takes.
|
|
1775
|
+
adapter) {
|
|
598
1776
|
this.stack = stack;
|
|
599
|
-
this.
|
|
1777
|
+
this.principalEntityId = principalEntityId;
|
|
1778
|
+
this.subjectEntityId = subjectEntityId;
|
|
1779
|
+
this.idTimestampSkewMs = idTimestampSkewMs;
|
|
1780
|
+
this.adapter = adapter;
|
|
600
1781
|
}
|
|
601
1782
|
get features() {
|
|
602
1783
|
return this.stack.features;
|
|
603
1784
|
}
|
|
604
|
-
resolveRecord = (id) => this.stack.get(id
|
|
1785
|
+
resolveRecord = (id) => this.stack.get(id);
|
|
1786
|
+
/** Whether a delegated app is acting for someone other than itself. */
|
|
1787
|
+
get delegated() {
|
|
1788
|
+
return this.subjectEntityId !== this.principalEntityId;
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Whether unconditional owner authority applies — the owner acting as
|
|
1792
|
+
* itself. The verbs that rest on it are irreversible or disclose the
|
|
1793
|
+
* sharing graph, so delegation never carries one to a subject, whichever
|
|
1794
|
+
* side the owner is on. See
|
|
1795
|
+
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
1796
|
+
*/
|
|
1797
|
+
get ownerActingAlone() {
|
|
1798
|
+
return !this.delegated && this.principalEntityId === this.stack.ownerEntityId;
|
|
1799
|
+
}
|
|
605
1800
|
checkRead(record) {
|
|
606
|
-
return checkAccess(record, this.
|
|
1801
|
+
return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'read', this.resolveRecord);
|
|
607
1802
|
}
|
|
608
1803
|
checkWrite(record) {
|
|
609
|
-
return checkAccess(record, this.
|
|
1804
|
+
return checkAccess(record, this.subjectEntityId, this.stack.ownerEntityId, 'write', this.resolveRecord);
|
|
610
1805
|
}
|
|
611
1806
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
1807
|
+
* Whether `grantee` holds a _grant covering one of `actions` for the
|
|
1808
|
+
* type's family (grants match by baseId, so a version bump never orphans
|
|
1809
|
+
* one). -own actions additionally require record.entityId === grantee,
|
|
1810
|
+
* unless `matchOwn` is false — on the principal side of a delegated
|
|
1811
|
+
* request the suffix is read as the bare verb, since which records are
|
|
1812
|
+
* reachable is the subject's business. `allowDefault` decides whether a
|
|
1813
|
+
* grant naming nobody counts. Anonymous grantees always return false.
|
|
1814
|
+
*
|
|
1815
|
+
* Reached only through subjectAllows()/principalAllows(), which fix those
|
|
1816
|
+
* two flags per side of the intersection. Call one of those instead.
|
|
1817
|
+
* See docs/spec/access-control.md § Type-level grants.
|
|
616
1818
|
*/
|
|
617
|
-
async hasGrant(typeId, actions,
|
|
618
|
-
|
|
1819
|
+
async hasGrant(typeId, actions, opts) {
|
|
1820
|
+
const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true } = opts;
|
|
1821
|
+
if (!grantee)
|
|
1822
|
+
return false;
|
|
1823
|
+
const familyId = baseIdOf(typeId);
|
|
1824
|
+
// grant() refuses to write these, but a _grant record is an ordinary
|
|
1825
|
+
// Record: an unscoped Stack, an import, or a server mapping a request
|
|
1826
|
+
// body onto Stack can mint one anyway. Refusing at the point of use is
|
|
1827
|
+
// what makes the rule hold regardless of how the record got there.
|
|
1828
|
+
if (UNGRANTABLE_SYSTEM_TYPES.has(familyId))
|
|
619
1829
|
return false;
|
|
620
1830
|
let grantRecords;
|
|
621
1831
|
if (prefetchedGrants !== undefined) {
|
|
622
1832
|
grantRecords = prefetchedGrants;
|
|
623
1833
|
}
|
|
624
1834
|
else {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
},
|
|
1835
|
+
// No content-field prefilter: a stored grant's typeId may be a bare
|
|
1836
|
+
// baseId or versioned, so exact matching would wrongly exclude family
|
|
1837
|
+
// versions. Cursor-walked to see grants past page one.
|
|
1838
|
+
grantRecords = await queryAllPages((q) => this.stack.query(q), {
|
|
1839
|
+
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
630
1840
|
});
|
|
631
|
-
grantRecords = result.records;
|
|
632
1841
|
}
|
|
633
1842
|
return grantRecords.some((r) => {
|
|
634
1843
|
const c = r.content;
|
|
635
|
-
if (c.typeId !==
|
|
1844
|
+
if (baseIdOf(c.typeId) !== familyId)
|
|
1845
|
+
return false;
|
|
1846
|
+
if (!c.granteeEntityId && !allowDefault)
|
|
636
1847
|
return false;
|
|
637
|
-
if (
|
|
1848
|
+
if (c.granteeEntityId && c.granteeEntityId !== grantee)
|
|
638
1849
|
return false;
|
|
639
1850
|
return actions.some((action) => {
|
|
640
1851
|
if (!c.actions.includes(action))
|
|
641
1852
|
return false;
|
|
642
|
-
if (action.endsWith('-own'))
|
|
643
|
-
return record?.entityId ===
|
|
1853
|
+
if (matchOwn && action.endsWith('-own'))
|
|
1854
|
+
return record?.entityId === grantee;
|
|
644
1855
|
return true;
|
|
645
1856
|
});
|
|
646
1857
|
});
|
|
647
1858
|
}
|
|
1859
|
+
/**
|
|
1860
|
+
* The principal half of a delegated request's authority: does the app
|
|
1861
|
+
* hold any grant permitting these verbs on this type at all. Bounds what
|
|
1862
|
+
* the subject's own authority can reach through it, so a powerful app
|
|
1863
|
+
* can never lend its reach to a weaker subject — nor the reverse.
|
|
1864
|
+
* Vacuously true when there's no delegation, where the principal and
|
|
1865
|
+
* subject checks would be the same question asked twice.
|
|
1866
|
+
*
|
|
1867
|
+
* Default grants don't count here. "Any authenticated entity" is about
|
|
1868
|
+
* people who turn up, not software the owner installed — an app reaches
|
|
1869
|
+
* only the types named to it, which is the whole of what containment
|
|
1870
|
+
* promises.
|
|
1871
|
+
*/
|
|
1872
|
+
principalAllows(typeId, actions, prefetchedGrants) {
|
|
1873
|
+
if (!this.delegated)
|
|
1874
|
+
return Promise.resolve(true);
|
|
1875
|
+
if (this.principalEntityId === this.stack.ownerEntityId)
|
|
1876
|
+
return Promise.resolve(true);
|
|
1877
|
+
return this.hasGrant(typeId, actions, {
|
|
1878
|
+
grantee: this.principalEntityId,
|
|
1879
|
+
prefetchedGrants,
|
|
1880
|
+
matchOwn: false,
|
|
1881
|
+
allowDefault: false,
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* The subject half: which records are reachable, answered with `-own`
|
|
1886
|
+
* matching and default grants both in force — the ordinary reading of a
|
|
1887
|
+
* grant, since the subject is the entity a grant is written about.
|
|
1888
|
+
*
|
|
1889
|
+
* Paired with principalAllows() so that the two halves of the
|
|
1890
|
+
* intersection are the only callers of hasGrant(): its flags differ per
|
|
1891
|
+
* side and mean nothing on their own, so no call site sets them by hand.
|
|
1892
|
+
*/
|
|
1893
|
+
subjectAllows(typeId, actions, opts = {}) {
|
|
1894
|
+
return this.hasGrant(typeId, actions, {
|
|
1895
|
+
grantee: this.subjectEntityId,
|
|
1896
|
+
record: opts.record,
|
|
1897
|
+
prefetchedGrants: opts.prefetchedGrants,
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
648
1900
|
async canRead(record, prefetchedGrants) {
|
|
649
|
-
|
|
650
|
-
(await this.
|
|
1901
|
+
const reachable = (await this.checkRead(record)) ||
|
|
1902
|
+
(await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
|
|
1903
|
+
record,
|
|
1904
|
+
prefetchedGrants,
|
|
1905
|
+
}));
|
|
1906
|
+
if (!reachable)
|
|
1907
|
+
return false;
|
|
1908
|
+
return this.principalAllows(record.typeId, ['read-own', 'read-any'], prefetchedGrants);
|
|
651
1909
|
}
|
|
652
1910
|
async checkCreateGrant(typeId) {
|
|
653
|
-
if (this.
|
|
1911
|
+
if (this.ownerActingAlone)
|
|
654
1912
|
return true;
|
|
655
|
-
|
|
1913
|
+
const reachable = this.subjectEntityId === this.stack.ownerEntityId ||
|
|
1914
|
+
(await this.subjectAllows(typeId, ['create']));
|
|
1915
|
+
if (!reachable)
|
|
1916
|
+
return false;
|
|
1917
|
+
return this.principalAllows(typeId, ['create']);
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* `_group` records are managed, not merely written: only the owner or an
|
|
1921
|
+
* `admin` roster holder may mutate them — ordinary write permissions and
|
|
1922
|
+
* grants don't apply. Asked of both identities under delegation, like
|
|
1923
|
+
* setPermissions(). See docs/spec/identity.md § Group.
|
|
1924
|
+
*/
|
|
1925
|
+
isGroupManager(record) {
|
|
1926
|
+
if (!this.managesGroup(this.principalEntityId, record))
|
|
1927
|
+
return false;
|
|
1928
|
+
return !this.delegated || this.managesGroup(this.subjectEntityId, record);
|
|
656
1929
|
}
|
|
657
|
-
/**
|
|
658
|
-
|
|
659
|
-
|
|
1930
|
+
/** Whether one identity, on its own, manages `record` — see isGroupManager(). */
|
|
1931
|
+
managesGroup(entityId, record) {
|
|
1932
|
+
if (!entityId)
|
|
1933
|
+
return false;
|
|
1934
|
+
if (entityId === this.stack.ownerEntityId)
|
|
1935
|
+
return true;
|
|
1936
|
+
return groupRoleFromAssociations(record.associations, entityId) === 'admin';
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Fetch a record the subject can reach and the principal holds `update` on
|
|
1940
|
+
* (via permissions or an update grant), or throw. `mutating: false` marks
|
|
1941
|
+
* the history readers, which borrow this gate without changing anything —
|
|
1942
|
+
* the one way through it a `_grant` Record stays open to.
|
|
1943
|
+
*/
|
|
1944
|
+
async requireUpdatable(id, opts = {}) {
|
|
1945
|
+
const record = await this.stack.get(id);
|
|
660
1946
|
if (!record)
|
|
661
1947
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
662
|
-
|
|
663
|
-
|
|
1948
|
+
if (opts.mutating ?? true)
|
|
1949
|
+
this.requireOwnerForGrantRecord(record.typeId);
|
|
1950
|
+
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
1951
|
+
if (!this.isGroupManager(record))
|
|
1952
|
+
throw new StackPermissionError();
|
|
1953
|
+
return record;
|
|
1954
|
+
}
|
|
1955
|
+
const allowed = ((await this.checkWrite(record)) ||
|
|
1956
|
+
(await this.subjectAllows(record.typeId, ['update-own', 'update-any'], { record }))) &&
|
|
1957
|
+
(await this.principalAllows(record.typeId, ['update-own', 'update-any']));
|
|
664
1958
|
if (!allowed)
|
|
665
1959
|
throw new StackPermissionError();
|
|
666
1960
|
return record;
|
|
667
1961
|
}
|
|
668
|
-
/**
|
|
1962
|
+
/**
|
|
1963
|
+
* Fetch a record the subject can reach and the principal holds `delete` on
|
|
1964
|
+
* (via permissions or a delete grant), or throw.
|
|
1965
|
+
*/
|
|
669
1966
|
async requireDeletable(id) {
|
|
670
|
-
const record = await this.stack.get(id
|
|
1967
|
+
const record = await this.stack.get(id);
|
|
671
1968
|
if (!record)
|
|
672
1969
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
673
|
-
|
|
674
|
-
|
|
1970
|
+
this.requireOwnerForGrantRecord(record.typeId);
|
|
1971
|
+
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
1972
|
+
if (!this.isGroupManager(record))
|
|
1973
|
+
throw new StackPermissionError();
|
|
1974
|
+
return record;
|
|
1975
|
+
}
|
|
1976
|
+
const allowed = ((await this.checkWrite(record)) ||
|
|
1977
|
+
(await this.subjectAllows(record.typeId, ['delete-own', 'delete-any'], { record }))) &&
|
|
1978
|
+
(await this.principalAllows(record.typeId, ['delete-own', 'delete-any']));
|
|
675
1979
|
if (!allowed)
|
|
676
1980
|
throw new StackPermissionError();
|
|
677
1981
|
return record;
|
|
678
1982
|
}
|
|
679
1983
|
/**
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
|
|
1984
|
+
* Whether this request may reference `recordId` (as a parentId or
|
|
1985
|
+
* relationship target). Missing and unreadable both return false —
|
|
1986
|
+
* indistinguishable, so this can't probe for a record's existence.
|
|
1987
|
+
*/
|
|
1988
|
+
async canReadReferent(recordId) {
|
|
1989
|
+
const record = await this.stack.get(recordId);
|
|
1990
|
+
if (!record)
|
|
1991
|
+
return false;
|
|
1992
|
+
return this.canRead(record);
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* Whether this request can read some record referencing `fileId` —
|
|
1996
|
+
* shared by canAccessFile() and the non-owner _attachment@1 create()
|
|
1997
|
+
* carve-out, which deliberately excludes the uploader clause. Walks
|
|
1998
|
+
* every referencing record, short-circuiting on the first readable one.
|
|
1999
|
+
* See docs/spec/attachments.md § Creating `_attachment@1` records directly.
|
|
2000
|
+
*/
|
|
2001
|
+
async hasReadableReference(fileId) {
|
|
2002
|
+
const prefetchedGrants = this.subjectEntityId
|
|
2003
|
+
? await queryAllPages((q) => this.stack.query(q), {
|
|
2004
|
+
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
2005
|
+
})
|
|
2006
|
+
: undefined;
|
|
2007
|
+
const match = await findFirstMatch((q) => this.stack.query(q), { filter: { attachmentFileId: fileId } }, (record) => this.canRead(record, prefetchedGrants));
|
|
2008
|
+
return match !== undefined;
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Whether this request may reference or download `fileId` — the dual of
|
|
2012
|
+
* getAttachment()'s access rule. Nonexistent and inaccessible are
|
|
2013
|
+
* indistinguishable (both false), so no confirmation oracle for guessed
|
|
2014
|
+
* hashes. See docs/spec/access-control.md § Reference-creation gating.
|
|
2015
|
+
*/
|
|
2016
|
+
async canAccessFile(fileId) {
|
|
2017
|
+
if (this.ownerActingAlone)
|
|
2018
|
+
return true;
|
|
2019
|
+
// Reaching a file through a record this request can read is already
|
|
2020
|
+
// fully intersected — canRead() applied the principal's mask against
|
|
2021
|
+
// that record's own type, which is the type the reference lives on.
|
|
2022
|
+
if (await this.hasReadableReference(fileId))
|
|
2023
|
+
return true;
|
|
2024
|
+
if (!this.subjectEntityId)
|
|
2025
|
+
return false;
|
|
2026
|
+
// The remaining paths are authorship facts about the subject, so they
|
|
2027
|
+
// decide *which* files match — they are not themselves a grant, and the
|
|
2028
|
+
// principal still needs one of its own on the attachment type.
|
|
2029
|
+
if (!(await this.principalAllows(`${SYSTEM_TYPES.ATTACHMENT}@1`, ['read-own', 'read-any']))) {
|
|
2030
|
+
return false;
|
|
2031
|
+
}
|
|
2032
|
+
if (this.subjectEntityId === this.stack.ownerEntityId)
|
|
2033
|
+
return true;
|
|
2034
|
+
return this.stack.features.contentFieldQuery
|
|
2035
|
+
? (await this.stack.query({
|
|
2036
|
+
filter: {
|
|
2037
|
+
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
|
|
2038
|
+
entityId: this.subjectEntityId,
|
|
2039
|
+
content: { fileId },
|
|
2040
|
+
},
|
|
2041
|
+
limit: 1,
|
|
2042
|
+
})).records.length > 0
|
|
2043
|
+
: (await queryAllPages((q) => this.stack.query(q), {
|
|
2044
|
+
filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, entityId: this.subjectEntityId },
|
|
2045
|
+
})).some((r) => r.content.fileId === fileId);
|
|
2046
|
+
}
|
|
2047
|
+
/** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
|
|
2048
|
+
async fileRefFieldNames(typeId) {
|
|
2049
|
+
const type = await this.stack.getType(typeId);
|
|
2050
|
+
if (!type)
|
|
2051
|
+
return [];
|
|
2052
|
+
return Object.entries(type.schema)
|
|
2053
|
+
.filter(([, def]) => def.kind === 'file-ref')
|
|
2054
|
+
.map(([field]) => field);
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Gates file-ref content fields on file access, mirroring the
|
|
2058
|
+
* attachment-association gate — a file-ref field conveys attachment
|
|
2059
|
+
* access exactly like an `attachment` association. Only fields present
|
|
2060
|
+
* in `content` are checked (update() is a merge patch; untouched fields
|
|
2061
|
+
* carry no new reference).
|
|
2062
|
+
*/
|
|
2063
|
+
async requireFileRefAccess(typeId, content) {
|
|
2064
|
+
for (const field of await this.fileRefFieldNames(typeId)) {
|
|
2065
|
+
const value = content[field];
|
|
2066
|
+
if (typeof value !== 'string')
|
|
2067
|
+
continue;
|
|
2068
|
+
if (!(await this.canAccessFile(value)))
|
|
2069
|
+
throw new StackPermissionError();
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Reference-creation gate for one association: `attachment` requires
|
|
2074
|
+
* file access, `relationship` read access to its target; `tag` is
|
|
2075
|
+
* unchecked, and `_group` roster associations are gated by the stricter
|
|
2076
|
+
* isGroupManager() instead. See docs/spec/access-control.md
|
|
2077
|
+
* § Reference-creation gating.
|
|
2078
|
+
*/
|
|
2079
|
+
async requireAssociationAccess(typeId, association) {
|
|
2080
|
+
if (association.kind === 'attachment') {
|
|
2081
|
+
if (!(await this.canAccessFile(association.fileId)))
|
|
2082
|
+
throw new StackPermissionError();
|
|
2083
|
+
}
|
|
2084
|
+
else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
|
|
2085
|
+
if (!(await this.canReadReferent(association.recordId)))
|
|
2086
|
+
throw new StackPermissionError();
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Create a record on behalf of the subject: create grant required,
|
|
2091
|
+
* anonymous denied, entityId set to the subject, client IDs skew-checked,
|
|
2092
|
+
* reference-creating options gated, and non-owner `_attachment@1`
|
|
2093
|
+
* creation refused save one carve-out. A scoped create always stamps
|
|
2094
|
+
* authorship — an absent entityId means an unscoped `Stack` wrote it.
|
|
2095
|
+
* See docs/spec/access-control.md and docs/spec/attachments.md.
|
|
684
2096
|
*/
|
|
685
2097
|
async create(typeId, content, opts = {}) {
|
|
686
|
-
const
|
|
687
|
-
if (!
|
|
2098
|
+
const principal = this.principalEntityId;
|
|
2099
|
+
if (!principal)
|
|
688
2100
|
throw new StackPermissionError('Anonymous requesters cannot create records');
|
|
689
2101
|
if (!(await this.checkCreateGrant(typeId))) {
|
|
690
2102
|
throw new StackPermissionError(`No create grant for type "${typeId}"`);
|
|
691
2103
|
}
|
|
692
|
-
|
|
2104
|
+
// The exemption is the owner's own, so delegation doesn't carry it: an
|
|
2105
|
+
// owner principal acting for someone else would otherwise let that
|
|
2106
|
+
// subject name any fileId and reach the bytes through the uploader
|
|
2107
|
+
// clause, which matches on the subject this create stamps.
|
|
2108
|
+
if (!this.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
|
|
2109
|
+
const fileId = content.fileId;
|
|
2110
|
+
if (typeof fileId !== 'string' || !(await this.hasReadableReference(fileId))) {
|
|
2111
|
+
throw new StackPermissionError();
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
if (opts.permissions?.length && !this.mayGrantAccess()) {
|
|
2115
|
+
throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
|
|
2116
|
+
}
|
|
2117
|
+
this.requireOwnerForOwnerDid(typeId, content.did);
|
|
2118
|
+
await this.requireAppIdMatchesPrincipal(opts.appId);
|
|
2119
|
+
if (opts.id !== undefined) {
|
|
2120
|
+
validateRecordId(opts.id);
|
|
2121
|
+
validateIdTimestampSkew(opts.id, this.idTimestampSkewMs);
|
|
2122
|
+
}
|
|
2123
|
+
if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
|
|
2124
|
+
throw new StackPermissionError();
|
|
2125
|
+
}
|
|
2126
|
+
for (const assoc of opts.associations ?? []) {
|
|
2127
|
+
await this.requireAssociationAccess(typeId, assoc);
|
|
2128
|
+
}
|
|
2129
|
+
await this.requireFileRefAccess(typeId, content);
|
|
2130
|
+
return this.stack.create(typeId, content, {
|
|
2131
|
+
...opts,
|
|
2132
|
+
entityId: this.subjectEntityId ?? undefined,
|
|
2133
|
+
principalId: this.delegated ? principal : undefined,
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Whether this request may decide who else reaches a record — the rule
|
|
2138
|
+
* setPermissions() enforces, asked at create time too so the reach it
|
|
2139
|
+
* withholds can't be taken one step earlier while authoring. A delegated
|
|
2140
|
+
* app is denied it: widening access is the one thing containment most
|
|
2141
|
+
* needs to hold. Refused rather than silently ignored, so an app never
|
|
2142
|
+
* believes it published something it didn't. Not `ownerActingAlone`:
|
|
2143
|
+
* the record is the subject's own, so an owner principal grants it no
|
|
2144
|
+
* reach the subject lacks.
|
|
2145
|
+
* See docs/spec/access-control.md § Delegation: principal and subject.
|
|
2146
|
+
*/
|
|
2147
|
+
mayGrantAccess() {
|
|
2148
|
+
return !this.delegated || this.principalEntityId === this.stack.ownerEntityId;
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Whether one identity, on its own, may decide who else reaches `record`
|
|
2152
|
+
* — the owner-or-creator rule setPermissions() enforces, asked of one
|
|
2153
|
+
* side at a time. See
|
|
2154
|
+
* docs/spec/access-control.md § Delegation: principal and subject.
|
|
2155
|
+
*/
|
|
2156
|
+
mayReshare(entityId, record) {
|
|
2157
|
+
if (!entityId)
|
|
2158
|
+
return false;
|
|
2159
|
+
return entityId === this.stack.ownerEntityId || entityId === record.entityId;
|
|
693
2160
|
}
|
|
694
2161
|
async get(id, opts = {}) {
|
|
695
2162
|
const record = await this.stack.get(id, opts);
|
|
@@ -700,26 +2167,20 @@ export class ScopedStack {
|
|
|
700
2167
|
return record;
|
|
701
2168
|
}
|
|
702
2169
|
/**
|
|
703
|
-
* Query records, filtered to
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
* so the returned page may overshoot `limit` slightly, but never skips a
|
|
708
|
-
* record that shared a page with the one that crossed the threshold (the
|
|
709
|
-
* adapter's cursor can't address a position mid-page).
|
|
710
|
-
*
|
|
711
|
-
* `total` is always null — see the QueryResult.total doc comment.
|
|
712
|
-
*
|
|
713
|
-
* Grant records are pre-fetched once before the pagination loop so read
|
|
714
|
-
* grants don't trigger a separate _grant@1 query per record.
|
|
2170
|
+
* Query records, filtered to those this request can read. Pages are
|
|
2171
|
+
* filtered then refilled, so a page may slightly overshoot `limit` but
|
|
2172
|
+
* never skips a record. `total` is always null (see QueryResult.total).
|
|
2173
|
+
* Grants are prefetched once, cursor-walked to exhaustion.
|
|
715
2174
|
*/
|
|
716
2175
|
async query(query = {}) {
|
|
717
2176
|
const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
|
|
718
2177
|
const records = [];
|
|
719
2178
|
const maxFetched = limit * 10;
|
|
720
2179
|
let totalFetched = 0;
|
|
721
|
-
const prefetchedGrants = this.
|
|
722
|
-
?
|
|
2180
|
+
const prefetchedGrants = this.principalEntityId
|
|
2181
|
+
? await queryAllPages((q) => this.stack.query(q), {
|
|
2182
|
+
filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` },
|
|
2183
|
+
})
|
|
723
2184
|
: undefined;
|
|
724
2185
|
let page = { records: [], cursor: query.cursor ?? null, total: null };
|
|
725
2186
|
do {
|
|
@@ -732,115 +2193,306 @@ export class ScopedStack {
|
|
|
732
2193
|
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
733
2194
|
return { records, cursor: page.cursor, total: null };
|
|
734
2195
|
}
|
|
735
|
-
async update(id, content) {
|
|
736
|
-
await this.requireUpdatable(id);
|
|
737
|
-
|
|
2196
|
+
async update(id, content, opts = {}) {
|
|
2197
|
+
const record = await this.requireUpdatable(id);
|
|
2198
|
+
// Value-wise, not presence-wise: a client that reads a card, edits its
|
|
2199
|
+
// `name` and sends the whole content object back is not setting the
|
|
2200
|
+
// binding it round-trips, and `name` is writable by record-level
|
|
2201
|
+
// permission. Same predicate restoreVersion() applies to a snapshot.
|
|
2202
|
+
this.requireOwnerForAppIdentity(record.typeId, (field) => field in content && content[field] !== record.content[field]);
|
|
2203
|
+
// Likewise value-wise: re-sending the DID a card already holds claims
|
|
2204
|
+
// nothing, and immutability refuses changing it regardless.
|
|
2205
|
+
if ('did' in content && content.did !== record.content.did) {
|
|
2206
|
+
this.requireOwnerForOwnerDid(record.typeId, content.did);
|
|
2207
|
+
}
|
|
2208
|
+
await this.requireFileRefAccess(record.typeId, content);
|
|
2209
|
+
return this.stack.update(id, content, opts);
|
|
738
2210
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
2211
|
+
/**
|
|
2212
|
+
* Naming the software behind a key is the trust decision the `_app`
|
|
2213
|
+
* registry exists to record, so both halves of that binding — `did` and
|
|
2214
|
+
* `appId` — belong to the owner alone. Same reasoning that makes `_app`
|
|
2215
|
+
* ungrantable, applied to the fields a lookup reads. Registering a card
|
|
2216
|
+
* is already owner-only; without this, record-level `write` shared on a
|
|
2217
|
+
* card would be a second way in: a card carrying no DID yet could be
|
|
2218
|
+
* pointed at a write-holder's own key while keeping the name the owner
|
|
2219
|
+
* gave it, or relabelled to claim another app's `appId`. `name` and
|
|
2220
|
+
* `version` stay writable — they are display, not lookup.
|
|
2221
|
+
*
|
|
2222
|
+
* Owner *acting alone*, in both directions: a delegated app never holds
|
|
2223
|
+
* it, and an owner principal doesn't lend it to a subject holding
|
|
2224
|
+
* record-level `write` on a card — which would reopen the same route
|
|
2225
|
+
* from the other side.
|
|
2226
|
+
*
|
|
2227
|
+
* `_entity` deliberately does not get this rule: naming people is what a
|
|
2228
|
+
* contacts app does, so its cards stay writable by grant. Uniqueness and
|
|
2229
|
+
* immutability still bind them. See docs/spec/identity.md § DID bindings.
|
|
2230
|
+
*/
|
|
2231
|
+
requireOwnerForAppIdentity(typeId, touches) {
|
|
2232
|
+
if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
|
|
2233
|
+
return;
|
|
2234
|
+
if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
|
|
2235
|
+
return;
|
|
2236
|
+
if (!this.ownerActingAlone) {
|
|
2237
|
+
throw new StackPermissionError('Only the stack owner may set an _app record’s did or appId');
|
|
2238
|
+
}
|
|
742
2239
|
}
|
|
743
|
-
|
|
2240
|
+
/**
|
|
2241
|
+
* A self-reported `appId` must agree with the `_app` card naming the
|
|
2242
|
+
* principal's DID, where the owner registered one. `principalId` is
|
|
2243
|
+
* verified, so letting the pair disagree would leave a verified principal
|
|
2244
|
+
* claiming a name the owner gave different software — the cross-check the
|
|
2245
|
+
* registry exists for, refused at the write instead of left to each reader.
|
|
2246
|
+
* A principal with no card keeps `appId` as the bare self-report it is for
|
|
2247
|
+
* every undelegated writer.
|
|
2248
|
+
* See docs/spec/identity.md § Attribution and what can be trusted.
|
|
2249
|
+
*/
|
|
2250
|
+
async requireAppIdMatchesPrincipal(appId) {
|
|
2251
|
+
if (appId === undefined || !this.delegated)
|
|
2252
|
+
return;
|
|
2253
|
+
const card = await findFirstMatch((q) => this.stack.query(q), {
|
|
2254
|
+
filter: {
|
|
2255
|
+
baseId: SYSTEM_TYPES.APP,
|
|
2256
|
+
includeDeleted: true,
|
|
2257
|
+
...(this.stack.features.contentFieldQuery && {
|
|
2258
|
+
content: { did: this.principalEntityId },
|
|
2259
|
+
}),
|
|
2260
|
+
},
|
|
2261
|
+
}, (r) => r.content.did === this.principalEntityId);
|
|
2262
|
+
if (card && card.content.appId !== appId) {
|
|
2263
|
+
throw new StackPermissionError(`appId "${appId}" is not the appId registered for this principal`);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* The owner's own DID is the one `_entity` binding a grantee may not claim.
|
|
2268
|
+
* `ownerProfile` adopts whichever card holds it, so a card minted by
|
|
2269
|
+
* someone else becomes the stack's own profile, and uniqueness then makes
|
|
2270
|
+
* that permanent. Every other DID stays open to a contacts app, which is
|
|
2271
|
+
* the reach `_entity` is grantable for.
|
|
2272
|
+
* See docs/spec/identity.md § DID bindings.
|
|
2273
|
+
*/
|
|
2274
|
+
requireOwnerForOwnerDid(typeId, did) {
|
|
2275
|
+
if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
|
|
2276
|
+
return;
|
|
2277
|
+
if (did !== this.stack.ownerEntityId)
|
|
2278
|
+
return;
|
|
2279
|
+
if (!this.ownerActingAlone) {
|
|
2280
|
+
throw new StackPermissionError('Only the stack owner may claim the owner’s own did');
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* A `_grant` Record *is* authority, so rewriting one is the escalation
|
|
2285
|
+
* UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
|
|
2286
|
+
* existing grant rather than minting a fresh one. `grant()` and `revoke()`
|
|
2287
|
+
* live on `Stack`, never `StackClient`, so no scoped write is lost. Writes
|
|
2288
|
+
* only: reading a grant Record and its history stays on the ordinary gate.
|
|
2289
|
+
* See docs/spec/access-control.md § Type-level grants.
|
|
2290
|
+
*/
|
|
2291
|
+
requireOwnerForGrantRecord(typeId) {
|
|
2292
|
+
if (baseIdOf(typeId) !== SYSTEM_TYPES.GRANT)
|
|
2293
|
+
return;
|
|
2294
|
+
if (!this.ownerActingAlone) {
|
|
2295
|
+
throw new StackPermissionError('Only the stack owner may write a _grant record');
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
async associate(id, association, opts = {}) {
|
|
2299
|
+
const record = await this.requireUpdatable(id);
|
|
2300
|
+
await this.requireAssociationAccess(record.typeId, association);
|
|
2301
|
+
return this.stack.associate(id, association, opts);
|
|
2302
|
+
}
|
|
2303
|
+
async dissociate(id, association, opts = {}) {
|
|
744
2304
|
await this.requireUpdatable(id);
|
|
745
|
-
return this.stack.dissociate(id, association);
|
|
2305
|
+
return this.stack.dissociate(id, association, opts);
|
|
746
2306
|
}
|
|
747
|
-
async setPermissions(id, permissions) {
|
|
748
|
-
const record = await this.stack.get(id
|
|
2307
|
+
async setPermissions(id, permissions, opts = {}) {
|
|
2308
|
+
const record = await this.stack.get(id);
|
|
749
2309
|
if (!record)
|
|
750
2310
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
2311
|
+
this.requireOwnerForGrantRecord(record.typeId);
|
|
2312
|
+
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) {
|
|
2313
|
+
// Group management, not authorship: a creator later demoted from the
|
|
2314
|
+
// admin roster shouldn't retain a side door to reassign who can read
|
|
2315
|
+
// or write the group record. Same gate as update/associate/delete.
|
|
2316
|
+
if (!this.isGroupManager(record))
|
|
2317
|
+
throw new StackPermissionError();
|
|
2318
|
+
}
|
|
2319
|
+
else {
|
|
2320
|
+
// Intersected like every other authority here: the principal must
|
|
2321
|
+
// hold the verb, and the subject must be able to reach this record —
|
|
2322
|
+
// without which an owner principal would carry its subject to records
|
|
2323
|
+
// the subject cannot touch. create() withholds the same reach via
|
|
2324
|
+
// mayGrantAccess().
|
|
2325
|
+
if (!this.mayReshare(this.principalEntityId, record))
|
|
2326
|
+
throw new StackPermissionError();
|
|
2327
|
+
if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) {
|
|
2328
|
+
throw new StackPermissionError();
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
return this.stack.setPermissions(id, permissions, opts);
|
|
756
2332
|
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Hard delete is owner-only: it is irreversible and destroys version
|
|
2335
|
+
* history, so neither the write bit nor delete-own/delete-any grants
|
|
2336
|
+
* reach it, and delegation doesn't carry it either. Everyone else is
|
|
2337
|
+
* limited to soft delete.
|
|
2338
|
+
*/
|
|
757
2339
|
async delete(id, opts = {}) {
|
|
758
2340
|
await this.requireDeletable(id);
|
|
2341
|
+
if (opts.hard && !this.ownerActingAlone) {
|
|
2342
|
+
throw new StackPermissionError('Hard delete is owner-only');
|
|
2343
|
+
}
|
|
759
2344
|
return this.stack.delete(id, opts);
|
|
760
2345
|
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Reverse a soft delete. Gated the same as delete() — undelete is the
|
|
2348
|
+
* inverse of soft delete, so granting one direction without the other
|
|
2349
|
+
* would be backwards. Idempotent, per Stack.undelete().
|
|
2350
|
+
*/
|
|
2351
|
+
async undelete(id, opts = {}) {
|
|
2352
|
+
await this.requireDeletable(id);
|
|
2353
|
+
return this.stack.undelete(id, opts);
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* History is the mutation/recovery surface, not a read surface — gated
|
|
2357
|
+
* like update(), with snapshot `permissions` stripped for everyone but
|
|
2358
|
+
* the owner acting alone — a snapshot's permissions are the stack's
|
|
2359
|
+
* sharing graph, which delegation is not a route to. Reading history
|
|
2360
|
+
* changes nothing, so it is the one path the `_grant` write fence leaves
|
|
2361
|
+
* alone: seeing how a Record you can already read got that way is not
|
|
2362
|
+
* the escalation that fence exists to stop, and losing it would leave a
|
|
2363
|
+
* write-holder unable to audit the Record they hold.
|
|
2364
|
+
* See docs/spec/versioning.md § History access.
|
|
2365
|
+
*/
|
|
761
2366
|
async getVersions(id) {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (!(await this.canRead(existing)))
|
|
766
|
-
throw new StackPermissionError();
|
|
767
|
-
return this.stack.getVersions(id);
|
|
2367
|
+
await this.requireUpdatable(id, { mutating: false });
|
|
2368
|
+
const versions = await this.stack.getVersions(id);
|
|
2369
|
+
return this.ownerActingAlone ? versions : versions.map(stripVersionPermissions);
|
|
768
2370
|
}
|
|
2371
|
+
/** See getVersions() — same mutate-surface gate, same permissions stripping. */
|
|
769
2372
|
async getVersion(id, version) {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
return this.stack.getVersion(id, version);
|
|
2373
|
+
await this.requireUpdatable(id, { mutating: false });
|
|
2374
|
+
const target = await this.stack.getVersion(id, version);
|
|
2375
|
+
if (!target)
|
|
2376
|
+
return null;
|
|
2377
|
+
return this.ownerActingAlone ? target : stripVersionPermissions(target);
|
|
776
2378
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
2379
|
+
/**
|
|
2380
|
+
* Re-runs the reference-creation checks against the snapshot, so a
|
|
2381
|
+
* restore can't re-convey access to a file or record the subject can no
|
|
2382
|
+
* longer reach today. Only the owner acting alone is exempt: under
|
|
2383
|
+
* delegation the checks resolve against the subject, which is whose reach
|
|
2384
|
+
* the restore would widen. See docs/spec/versioning.md § Restore semantics.
|
|
2385
|
+
*/
|
|
2386
|
+
async restoreVersion(id, version, opts = {}) {
|
|
2387
|
+
const record = await this.requireUpdatable(id);
|
|
2388
|
+
if (!this.ownerActingAlone) {
|
|
2389
|
+
const target = await this.stack.getVersion(id, version);
|
|
2390
|
+
if (target) {
|
|
2391
|
+
// A rollback that would move a card's binding is the same trust
|
|
2392
|
+
// decision update() reserves to the owner, reached by another route.
|
|
2393
|
+
this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
|
|
2394
|
+
record.content[field]);
|
|
2395
|
+
await this.requireFileRefAccess(target.typeId, target.content);
|
|
2396
|
+
for (const association of target.associations ?? []) {
|
|
2397
|
+
await this.requireAssociationAccess(target.typeId, association);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
return this.stack.restoreVersion(id, version, opts);
|
|
780
2402
|
}
|
|
781
2403
|
/**
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
2404
|
+
* Commit a per-record migration — **the owner acting alone, only**.
|
|
2405
|
+
*
|
|
2406
|
+
* Migration is owner-driven by design: `migrateAll()`, the bulk path,
|
|
2407
|
+
* lives on `Stack` and is deliberately absent from `StackClient`, the
|
|
2408
|
+
* same way `grant()`/`revoke()` are. This is its per-record counterpart
|
|
2409
|
+
* and carries the same restriction, rather than inventing a grant model
|
|
2410
|
+
* that the bulk path deliberately doesn't have.
|
|
2411
|
+
*
|
|
2412
|
+
* The restriction is what makes the verb safe to expose at all. Migrate
|
|
2413
|
+
* replaces `content` and `typeId` wholesale, so a grant-based version
|
|
2414
|
+
* would have to re-derive every gate `create()` applies at the
|
|
2415
|
+
* destination *and* every gate `update()` applies over the existing
|
|
2416
|
+
* content, and would reopen each one it missed. The sharpest is the
|
|
2417
|
+
* non-owner `_attachment@1` refusal create() carries: without it, a
|
|
2418
|
+
* requester holding a create grant on `_attachment@1` and write access
|
|
2419
|
+
* to any record they authored could migrate that record into the family
|
|
2420
|
+
* naming any `fileId`, then read the bytes through canAccessFile()'s
|
|
2421
|
+
* uploader clause — the exact escalation that carve-out exists to refuse
|
|
2422
|
+
* (see docs/spec/attachments.md § Creating `_attachment@1` records
|
|
2423
|
+
* directly). Ordinary write access to a record is not consent to move it
|
|
2424
|
+
* between families.
|
|
2425
|
+
*
|
|
2426
|
+
* A server implementing `POST /records/:id/migrate` therefore serves it
|
|
2427
|
+
* to the stack owner and answers 403 otherwise. See
|
|
2428
|
+
* docs/spec/data-model.md § Type migrations.
|
|
2429
|
+
*/
|
|
2430
|
+
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
2431
|
+
if (!this.ownerActingAlone) {
|
|
2432
|
+
throw new StackPermissionError('Only the stack owner may commit a migration');
|
|
2433
|
+
}
|
|
2434
|
+
return this.stack.commitMigration(id, toTypeId, content, opts);
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Store bytes and create an _attachment@1 metadata record (create grant
|
|
2438
|
+
* on `_attachment@1` required; anonymous denied), returning that record.
|
|
2439
|
+
* Authorship and principal are stamped exactly as create() does.
|
|
785
2440
|
*/
|
|
786
|
-
async putAttachment(data, mimeType, filename) {
|
|
787
|
-
|
|
788
|
-
|
|
2441
|
+
async putAttachment(data, mimeType, filename, appId) {
|
|
2442
|
+
// The one ScopedStack path that reaches the adapter without going
|
|
2443
|
+
// through Stack first — without this, a closed stack would still write
|
|
2444
|
+
// bytes before the delegated create() refused.
|
|
2445
|
+
this.stack.assertOpen();
|
|
2446
|
+
const principal = this.principalEntityId;
|
|
2447
|
+
if (!principal) {
|
|
789
2448
|
throw new StackPermissionError('Anonymous requesters cannot upload attachments');
|
|
790
2449
|
}
|
|
791
2450
|
if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) {
|
|
792
2451
|
throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`);
|
|
793
2452
|
}
|
|
794
|
-
|
|
795
|
-
|
|
2453
|
+
await this.requireAppIdMatchesPrincipal(appId);
|
|
2454
|
+
assertAttachmentSize(data.byteLength, this.features.maxAttachmentBytes);
|
|
2455
|
+
const fileId = await this.adapter.putAttachment(data);
|
|
2456
|
+
return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
|
|
796
2457
|
fileId,
|
|
797
2458
|
mimeType,
|
|
798
2459
|
size: data.byteLength,
|
|
799
2460
|
...(filename && { filename }),
|
|
800
|
-
}, {
|
|
801
|
-
|
|
2461
|
+
}, {
|
|
2462
|
+
entityId: this.subjectEntityId ?? undefined,
|
|
2463
|
+
principalId: this.delegated ? principal : undefined,
|
|
2464
|
+
appId,
|
|
2465
|
+
});
|
|
802
2466
|
}
|
|
803
2467
|
/**
|
|
804
|
-
* Download attachment bytes. Accessible
|
|
805
|
-
*
|
|
806
|
-
*
|
|
2468
|
+
* Download attachment bytes. Accessible to the owner, a reader of any
|
|
2469
|
+
* referencing record, or the uploader pre-association — the same
|
|
2470
|
+
* predicate as the reference-creation gate (canAccessFile).
|
|
807
2471
|
*/
|
|
808
2472
|
async getAttachment(fileId) {
|
|
809
|
-
if (
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
// Accessible if the requester can read any record that references this file
|
|
813
|
-
const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
|
|
814
|
-
if (refResult.records.length > 0) {
|
|
815
|
-
return this.stack.getAttachment(fileId);
|
|
816
|
-
}
|
|
817
|
-
// Accessible if the requester uploaded it and it hasn't been associated yet
|
|
818
|
-
if (this.requesterEntityId) {
|
|
819
|
-
const uploadResult = await this.stack.query({
|
|
820
|
-
filter: {
|
|
821
|
-
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
|
|
822
|
-
entityId: this.requesterEntityId,
|
|
823
|
-
...(this.stack.features.contentFieldQuery && { content: { fileId } }),
|
|
824
|
-
},
|
|
825
|
-
limit: 1,
|
|
826
|
-
});
|
|
827
|
-
const hasUpload = this.stack.features.contentFieldQuery
|
|
828
|
-
? uploadResult.records.length > 0
|
|
829
|
-
: uploadResult.records.some((r) => r.content.fileId === fileId);
|
|
830
|
-
if (hasUpload)
|
|
831
|
-
return this.stack.getAttachment(fileId);
|
|
832
|
-
}
|
|
833
|
-
throw new StackPermissionError();
|
|
2473
|
+
if (!(await this.canAccessFile(fileId)))
|
|
2474
|
+
throw new StackPermissionError();
|
|
2475
|
+
return this.stack.getAttachment(fileId);
|
|
834
2476
|
}
|
|
835
2477
|
/**
|
|
836
2478
|
* Delete an attachment. Only the stack owner may delete attachments.
|
|
837
2479
|
* Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
|
|
838
2480
|
*/
|
|
839
2481
|
async deleteAttachment(fileId) {
|
|
840
|
-
if (this.
|
|
2482
|
+
if (!this.ownerActingAlone) {
|
|
841
2483
|
throw new StackPermissionError('Only the stack owner can delete attachments');
|
|
842
2484
|
}
|
|
843
2485
|
return this.stack.deleteAttachment(fileId);
|
|
844
2486
|
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Sweep for unreferenced attachment bytes and delete them. Only the stack
|
|
2489
|
+
* owner may run this. Delegates to Stack.collectAttachmentGarbage().
|
|
2490
|
+
*/
|
|
2491
|
+
async collectAttachmentGarbage(opts) {
|
|
2492
|
+
if (!this.ownerActingAlone) {
|
|
2493
|
+
throw new StackPermissionError('Only the stack owner can collect attachment garbage');
|
|
2494
|
+
}
|
|
2495
|
+
return this.stack.collectAttachmentGarbage(opts);
|
|
2496
|
+
}
|
|
845
2497
|
}
|
|
846
2498
|
//# sourceMappingURL=stack.js.map
|