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