@civitai/sdk 0.13.1 → 0.14.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.
@@ -0,0 +1,1141 @@
1
+ import { ApiError } from '../http/index.js';
2
+ import { createDocumentStorage } from './index.js';
3
+ /**
4
+ * An in-memory document store behind the `Http` seam the client takes, enforcing the
5
+ * server's rules: the same routes, gates, check ORDER, limits, rate limits, error
6
+ * codes, statuses and envelope as `/api/v1/blocks/storage/…`.
7
+ *
8
+ * It is a port of the server's route layer and store, not a sketch of them. The
9
+ * vendored contract (`schemas/app-block/storage-contract.v1.json`) runs against it in
10
+ * this package's suite, through the raw `http` and through the real client, so where
11
+ * the contract describes a rule the fake and the server agree by construction. Rules
12
+ * the contract does not exercise are ported from the server source and say so where
13
+ * they diverge.
14
+ *
15
+ * WHERE IT DIFFERS ON PURPOSE: rate limits apply only with `enforceRateLimits: true`;
16
+ * a declaration using a feature the platform has not shipped (`write: "owner"` or
17
+ * `"anyone"`, log collections, `ttlSeconds`, `dailyWritesPerAuthor`) is refused when
18
+ * the fake is CREATED rather than per request; and a list cursor is an opaque token
19
+ * bound to its whole query, not the server's sort-bound keyset string.
20
+ *
21
+ * NOT MODELLED: moderator hides (a hidden document reads as not found on the server;
22
+ * nothing here hides one), moderator exemption from the scanner, the review-preview
23
+ * namespace, and the token middleware's other refusals (revoked instance, context
24
+ * binding, an approval read that fails).
25
+ */
26
+ // ---------------------------------------------------------------------------
27
+ // Bounds — the server's, one copy here (the contract run pins the ones it reaches)
28
+ // ---------------------------------------------------------------------------
29
+ const BASE_SCOPE = 'apps:storage';
30
+ const PUBLISH_SCOPE = 'apps:storage:publish';
31
+ /** Scopes the host strips when the viewer revokes consent; `apps:storage` is consent-exempt. */
32
+ const CONSENT_GATED = new Set([PUBLISH_SCOPE]);
33
+ const COLLECTION_NAME = /^[a-z][a-z0-9_]{0,31}$/;
34
+ const TALLY_NAME = /^[a-z][a-z0-9_]{0,31}$/;
35
+ const KEY = /^[A-Za-z0-9._:-]{1,128}$/;
36
+ const DOT_ONLY = /^\.+$/;
37
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
38
+ const REPORT = 'report';
39
+ const INDEX_TYPES = ['string', 'number', 'boolean', 'time'];
40
+ const B = {
41
+ docBytes: 65536,
42
+ depth: 16,
43
+ stringIndexBytes: 128,
44
+ equalityFilters: 1,
45
+ listLimitMax: 100,
46
+ listLimitDefault: 25,
47
+ getManyMax: 100,
48
+ incrementMax: 100,
49
+ };
50
+ /** One document plus the request wrapper around it. */
51
+ const BODY_LIMIT_BYTES = B.docBytes + 16 * 1024;
52
+ const DEFAULT_LIMITS = {
53
+ appBytes: 50 * 1024 * 1024,
54
+ appDocs: 1_000_000,
55
+ viewerBytes: 2 * 1024 * 1024,
56
+ viewerDocs: 1_000,
57
+ };
58
+ const MINUTE = 60;
59
+ const DAY = 24 * 60 * 60;
60
+ const RATE = {
61
+ read: { max: 300, windowSeconds: MINUTE },
62
+ write: { max: 120, windowSeconds: MINUTE },
63
+ tally: { max: 30, windowSeconds: MINUTE },
64
+ report: { max: 20, windowSeconds: DAY },
65
+ };
66
+ /** burst, daily, rejections: [capacity, refill interval ms]. */
67
+ const SHARED_WRITE_BUDGET = [
68
+ [10, 6 * 60 * 1000],
69
+ [50, (DAY * 1000) / 50],
70
+ [10, 30 * 60 * 1000],
71
+ ];
72
+ /** The default scanner's words: the contract's `conventions`, so a test can reach each refusal. */
73
+ export const FAKE_STORAGE_MODERATION = Object.freeze({
74
+ blockedText: 'contract-blocked-term',
75
+ blockedLink: 'https://contract-blocked.example/x',
76
+ scannerDown: 'contract-scanner-down',
77
+ });
78
+ const STATUS = {
79
+ invalid_request: 400,
80
+ index_not_declared: 400,
81
+ unauthenticated: 401,
82
+ sign_in_required: 401,
83
+ insufficient_scope: 403,
84
+ consent_required: 403,
85
+ not_eligible: 403,
86
+ not_author: 403,
87
+ write_policy: 403,
88
+ not_found: 404,
89
+ collection_not_declared: 404,
90
+ method_not_allowed: 405,
91
+ version_conflict: 409,
92
+ too_large: 413,
93
+ quota_exceeded: 413,
94
+ content_rejected: 422,
95
+ rate_limited: 429,
96
+ storage_disabled: 503,
97
+ moderation_unavailable: 503,
98
+ consent_unavailable: 503,
99
+ };
100
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
101
+ /** Declarations the platform does not serve yet, each with what to do instead. */
102
+ const PHASED = [
103
+ { test: (r) => r.write === 'owner', what: 'write "owner" (owner-written documents)' },
104
+ { test: (r) => r.write === 'anyone', what: 'write "anyone" (documents any viewer may edit)' },
105
+ { test: (r) => r.kind === 'log' || r.maxEntries !== undefined, what: 'kind "log" / maxEntries (log collections)' },
106
+ { test: (r) => r.ttlSeconds !== undefined, what: 'ttlSeconds (expiring documents)' },
107
+ { test: (r) => r.dailyWritesPerAuthor !== undefined, what: 'dailyWritesPerAuthor (per-collection write budgets)' },
108
+ ];
109
+ /**
110
+ * Only what the fake needs to know the declared collections. It is NOT a manifest
111
+ * validator — `civitai app validate` and the platform's review are; a declaration it
112
+ * reads here may still be refused there. It refuses, at construction, the two things
113
+ * it cannot serve: a declaration it cannot read, and a feature the platform has not
114
+ * shipped (so a test never passes against storage that would not exist).
115
+ */
116
+ function parseManifest(manifest) {
117
+ const out = new Map();
118
+ if (!isObject(manifest) || manifest.storage === undefined)
119
+ return out;
120
+ const storage = manifest.storage;
121
+ if (!isObject(storage) || !isObject(storage.collections)) {
122
+ throw new Error('createFakeStorage: manifest.storage.collections must be an object');
123
+ }
124
+ const errors = [];
125
+ const names = (v) => (Array.isArray(v) ? v.filter((n) => typeof n === 'string') : []);
126
+ for (const [name, raw] of Object.entries(storage.collections)) {
127
+ const at = `storage.collections.${name}`;
128
+ if (!isObject(raw) || (raw.visibility !== 'private' && raw.visibility !== 'shared')) {
129
+ errors.push(`${at}: needs visibility "private" or "shared"`);
130
+ continue;
131
+ }
132
+ for (const { test, what } of PHASED) {
133
+ if (test(raw))
134
+ errors.push(`${at}: ${what} is not available yet in App Storage`);
135
+ }
136
+ const shared = raw.visibility === 'shared';
137
+ if (shared && raw.write !== 'own' && raw.write !== 'none' && raw.write !== 'owner' && raw.write !== 'anyone') {
138
+ errors.push(`${at}: a shared collection needs write "own" or "none"`);
139
+ }
140
+ const indexes = new Map();
141
+ for (const [path, type] of Object.entries(isObject(raw.indexes) ? raw.indexes : {})) {
142
+ if (INDEX_TYPES.includes(type))
143
+ indexes.set(path, type);
144
+ else
145
+ errors.push(`${at}.indexes.${path}: type must be one of ${INDEX_TYPES.join(', ')}`);
146
+ }
147
+ const decl = {
148
+ visibility: raw.visibility,
149
+ counters: shared ? names(raw.counters) : [],
150
+ marks: shared ? names(raw.marks) : [],
151
+ indexes,
152
+ showAuthor: shared && raw.showAuthor === true,
153
+ };
154
+ if (shared && (raw.write === 'own' || raw.write === 'none'))
155
+ decl.write = raw.write;
156
+ out.set(name, decl);
157
+ }
158
+ if (errors.length > 0)
159
+ throw new Error(`createFakeStorage: cannot serve this storage declaration:\n- ${errors.join('\n- ')}`);
160
+ return out;
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Errors
164
+ // ---------------------------------------------------------------------------
165
+ class Refusal extends Error {
166
+ code;
167
+ details;
168
+ constructor(code, message, details) {
169
+ super(message);
170
+ this.code = code;
171
+ if (details)
172
+ this.details = details;
173
+ }
174
+ }
175
+ const refuse = (code, message, details) => new Refusal(code, message, details);
176
+ const invalid = (message, details) => refuse('invalid_request', message, details);
177
+ const utf8Length = (text) => new TextEncoder().encode(text).length;
178
+ /**
179
+ * String order as the server's `COLLATE "C"` columns sort: by Unicode code point
180
+ * (the byte order of UTF-8). JavaScript's `<` compares UTF-16 code units instead,
181
+ * which puts an astral character (a surrogate pair, 0xD800…) BEFORE U+E000–U+FFFF.
182
+ */
183
+ function byCodePoint(a, b) {
184
+ const x = Array.from(a, (c) => c.codePointAt(0));
185
+ const y = Array.from(b, (c) => c.codePointAt(0));
186
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
187
+ if (x[i] !== y[i])
188
+ return x[i] < y[i] ? -1 : 1;
189
+ }
190
+ return x.length - y.length < 0 ? -1 : x.length > y.length ? 1 : 0;
191
+ }
192
+ /**
193
+ * The stored unit: the length of the database's text form of the JSON, which puts a
194
+ * space after every `:` and `,`. (Exact except where the two spell a number
195
+ * differently, e.g. an exponent.)
196
+ */
197
+ function storedSize(data) {
198
+ let separators = 0;
199
+ const walk = (v) => {
200
+ if (Array.isArray(v)) {
201
+ separators += Math.max(0, v.length - 1);
202
+ v.forEach(walk);
203
+ }
204
+ else if (isObject(v)) {
205
+ const entries = Object.entries(v);
206
+ separators += entries.length + Math.max(0, entries.length - 1);
207
+ for (const [, x] of entries)
208
+ walk(x);
209
+ }
210
+ };
211
+ walk(data);
212
+ return utf8Length(JSON.stringify(data)) + separators;
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // The fake
216
+ // ---------------------------------------------------------------------------
217
+ export function createFakeStorage(options) {
218
+ const collections = parseManifest(options.manifest);
219
+ const now = options.now ?? (() => Date.now());
220
+ const limits = { ...DEFAULT_LIMITS, ...options.limits };
221
+ const moderate = options.moderate ??
222
+ ((text) => {
223
+ if (text.includes(FAKE_STORAGE_MODERATION.scannerDown))
224
+ return 'unavailable';
225
+ if (text.includes(FAKE_STORAGE_MODERATION.blockedLink))
226
+ return 'link';
227
+ if (text.includes(FAKE_STORAGE_MODERATION.blockedText))
228
+ return 'blocked';
229
+ return null;
230
+ });
231
+ const defaultViewer = options.viewer === undefined ? { username: 'viewer' } : options.viewer;
232
+ const calls = [];
233
+ // Identity: a username → an internal id that no response ever carries.
234
+ const uids = new Map();
235
+ const uidOf = (viewer) => {
236
+ let uid = uids.get(viewer.username);
237
+ if (uid === undefined) {
238
+ uid = viewer.id ?? uids.size + 1;
239
+ uids.set(viewer.username, uid);
240
+ }
241
+ return uid;
242
+ };
243
+ const usernames = () => new Map([...uids].map(([name, uid]) => [uid, name]));
244
+ const docs = new Map();
245
+ const appUsage = new Map();
246
+ const userUsage = new Map();
247
+ const windows = new Map();
248
+ const budgets = new Map();
249
+ const cursors = new Map();
250
+ const issueCursor = (binding, value, key) => {
251
+ // Deterministic (a per-fake counter), so an app's snapshot tests are stable.
252
+ const token = `cursor_${cursors.size + 1}`;
253
+ cursors.set(token, { binding, value, key });
254
+ return token;
255
+ };
256
+ const scopeOf = (ctx, coll) => {
257
+ if (collections.get(coll).visibility === 'shared')
258
+ return 0;
259
+ if (ctx.uid === null)
260
+ throw refuse('sign_in_required', 'sign in required');
261
+ return ctx.uid;
262
+ };
263
+ const docId = (ctx, coll, scope, key) => `${ctx.ns}\u0000${coll}\u0000${scope}\u0000${key}`;
264
+ const docsIn = (ctx, coll, scope) => {
265
+ const prefix = `${ctx.ns}\u0000${coll}\u0000${scope}\u0000`;
266
+ return [...docs].filter(([id]) => id.startsWith(prefix)).map(([, d]) => d);
267
+ };
268
+ // --- rate limits ---------------------------------------------------------
269
+ /** A fixed window from the first request; every request counts, refused ones too. */
270
+ function charge(bucket, uid) {
271
+ if (!options.enforceRateLimits)
272
+ return;
273
+ const { max, windowSeconds } = RATE[bucket];
274
+ const id = `${bucket}:${uid === null ? 'ip' : `u:${uid}`}`;
275
+ const t = now();
276
+ let w = windows.get(id);
277
+ if (!w || w.resetAt <= t) {
278
+ w = { count: 0, resetAt: t + windowSeconds * 1000 };
279
+ windows.set(id, w);
280
+ }
281
+ w.count += 1;
282
+ if (w.count > max) {
283
+ const left = Math.ceil((w.resetAt - t) / 1000);
284
+ throw refuse('rate_limited', 'rate limit exceeded', { bucket, retryAfter: left >= 1 ? left : windowSeconds });
285
+ }
286
+ }
287
+ /** Three continuously refilling buckets; each slot is the instant that bucket is full again. */
288
+ function reserveSharedWrite(uid) {
289
+ if (!options.enforceRateLimits)
290
+ return () => undefined;
291
+ const t = now();
292
+ const full = (budgets.get(uid) ?? [t, t, t]).map((f) => Math.max(f, t));
293
+ let wait = 0;
294
+ SHARED_WRITE_BUDGET.forEach(([cap, iv], i) => {
295
+ wait = Math.max(wait, full[i] - t - (cap - 1) * iv);
296
+ });
297
+ if (wait > 0) {
298
+ throw refuse('rate_limited', 'shared write budget exhausted', {
299
+ bucket: 'shared-write',
300
+ retryAfter: Math.max(1, Math.ceil(wait / 1000)),
301
+ });
302
+ }
303
+ full[0] += SHARED_WRITE_BUDGET[0][1];
304
+ full[1] += SHARED_WRITE_BUDGET[1][1];
305
+ budgets.set(uid, full);
306
+ return (rejected) => {
307
+ const t2 = now();
308
+ const f = (budgets.get(uid) ?? [t2, t2, t2]).map((x) => Math.max(x, t2));
309
+ f[0] = Math.max(f[0] - SHARED_WRITE_BUDGET[0][1], t2);
310
+ f[1] = Math.max(f[1] - SHARED_WRITE_BUDGET[1][1], t2);
311
+ if (rejected) {
312
+ const [cap, iv] = SHARED_WRITE_BUDGET[2];
313
+ f[2] = Math.min(f[2] + iv, t2 + 2 * cap * iv);
314
+ }
315
+ budgets.set(uid, f);
316
+ };
317
+ }
318
+ // --- store ---------------------------------------------------------------
319
+ /** Phased features were refused at construction, so every declared collection is servable. */
320
+ const declOf = (name) => collections.get(name);
321
+ function assertShareWritable(decl) {
322
+ if (decl.visibility !== 'shared')
323
+ return;
324
+ if (decl.write === 'none')
325
+ throw refuse('write_policy', 'documents in this collection are written by counters only');
326
+ }
327
+ function assertKey(key) {
328
+ if (typeof key !== 'string' || !KEY.test(key))
329
+ throw invalid('key must be 1–128 characters of [A-Za-z0-9._:-]');
330
+ if (DOT_ONLY.test(key))
331
+ throw invalid('key must not consist only of dots: it cannot be addressed in a URL path');
332
+ }
333
+ const requireViewer = (ctx) => {
334
+ if (ctx.uid === null)
335
+ throw refuse('sign_in_required', 'sign in required');
336
+ return ctx.uid;
337
+ };
338
+ const requireShared = (decl, what) => {
339
+ if (decl.visibility !== 'shared')
340
+ throw invalid(`${what} exist on shared collections only`);
341
+ };
342
+ function assertStoredString(s) {
343
+ if (s.includes('\u0000') || LONE_SURROGATE.test(s))
344
+ throw invalid('data contains a NUL character or an unpaired surrogate');
345
+ }
346
+ function assertShape(v, depth) {
347
+ if (typeof v === 'string')
348
+ return assertStoredString(v);
349
+ if (typeof v === 'number') {
350
+ if (!Number.isFinite(v))
351
+ throw invalid('data contains a non-finite number');
352
+ return;
353
+ }
354
+ if (v === null || typeof v === 'boolean')
355
+ return;
356
+ if (typeof v !== 'object')
357
+ throw invalid('data contains a non-JSON value');
358
+ if (depth > B.depth)
359
+ throw invalid(`data nests deeper than ${B.depth}`);
360
+ if (Array.isArray(v))
361
+ return v.forEach((x) => assertShape(x, depth + 1));
362
+ for (const [k, x] of Object.entries(v)) {
363
+ assertStoredString(k);
364
+ assertShape(x, depth + 1);
365
+ }
366
+ }
367
+ function serialize(data) {
368
+ if (!isObject(data))
369
+ throw invalid('data must be a JSON object');
370
+ if (utf8Length(JSON.stringify(data)) > B.docBytes) {
371
+ throw refuse('too_large', `document exceeds ${B.docBytes} bytes`, { limit: B.docBytes });
372
+ }
373
+ assertShape(data, 1);
374
+ return data;
375
+ }
376
+ function indexValue(type, raw, index) {
377
+ const bad = (reason) => invalid(`index ${index}: ${reason}`, { index });
378
+ switch (type) {
379
+ case 'string':
380
+ if (typeof raw !== 'string')
381
+ throw bad('expected a string');
382
+ if (utf8Length(raw) > B.stringIndexBytes)
383
+ throw bad(`string longer than ${B.stringIndexBytes} bytes`);
384
+ assertStoredString(raw);
385
+ return { type, text: raw };
386
+ case 'number':
387
+ if (typeof raw !== 'number' || !Number.isFinite(raw))
388
+ throw bad('expected a finite number');
389
+ return { type, num: raw };
390
+ case 'boolean':
391
+ if (typeof raw !== 'boolean')
392
+ throw bad('expected a boolean');
393
+ return { type, num: raw ? 1 : 0 };
394
+ case 'time': {
395
+ const ms = typeof raw === 'number' ? raw : typeof raw === 'string' ? Date.parse(raw) : Number.NaN;
396
+ if (!Number.isFinite(ms))
397
+ throw bad('expected an ISO-8601 time or epoch milliseconds');
398
+ return { type, num: ms };
399
+ }
400
+ }
401
+ }
402
+ function indexRows(decl, data) {
403
+ const rows = new Map();
404
+ for (const [name, type] of decl.indexes) {
405
+ let cur = data;
406
+ for (const seg of name.split('.')) {
407
+ cur = isObject(cur) && Object.prototype.hasOwnProperty.call(cur, seg) ? cur[seg] : undefined;
408
+ }
409
+ if (cur === undefined || cur === null)
410
+ continue;
411
+ rows.set(name, indexValue(type, cur, name));
412
+ }
413
+ return rows;
414
+ }
415
+ /** The ceilings, checked before anything changes, so a refusal leaves the store as it was. */
416
+ function chargeUsage(ctx, uid, bytes, count, ceiling) {
417
+ const app = appUsage.get(ctx.ns) ?? { bytes: 0, docs: 0 };
418
+ if ((bytes > 0 && app.bytes + bytes > limits.appBytes) || (count > 0 && app.docs + count > limits.appDocs)) {
419
+ throw refuse('quota_exceeded', 'app storage quota exceeded', { ceiling: 'app' });
420
+ }
421
+ const userId = `${ctx.ns}\u0000${uid}`;
422
+ const user = userUsage.get(userId) ?? { bytes: 0, docs: 0 };
423
+ if ((bytes !== 0 || count !== 0) &&
424
+ ((bytes > 0 && user.bytes + bytes > limits.viewerBytes) || (count > 0 && user.docs + count > limits.viewerDocs))) {
425
+ throw refuse('quota_exceeded', 'storage quota exceeded', { ceiling });
426
+ }
427
+ return () => {
428
+ appUsage.set(ctx.ns, { bytes: app.bytes + bytes, docs: app.docs + count });
429
+ userUsage.set(userId, { bytes: user.bytes + bytes, docs: user.docs + count });
430
+ };
431
+ }
432
+ function present(ctx, coll, d) {
433
+ const decl = collections.get(coll);
434
+ const counters = {};
435
+ const marks = {};
436
+ if (decl.visibility === 'shared') {
437
+ for (const n of decl.counters)
438
+ counters[n] = d.counters.get(n) ?? 0;
439
+ for (const n of decl.marks)
440
+ if (n !== REPORT)
441
+ marks[n] = d.markCounts.get(n) ?? 0;
442
+ }
443
+ const names = usernames();
444
+ const author = d.authorKind === 'counter'
445
+ ? { kind: 'counter' }
446
+ : {
447
+ kind: 'user',
448
+ isViewer: d.authorUid === ctx.uid,
449
+ ...(decl.showAuthor && names.has(d.authorUid) ? { username: names.get(d.authorUid) } : {}),
450
+ };
451
+ const mine = ctx.uid === null
452
+ ? null
453
+ : {
454
+ marks: [...d.marks]
455
+ .filter((m) => decl.visibility === 'shared' && m.startsWith(`${ctx.uid}:`))
456
+ .map((m) => m.slice(m.indexOf(':') + 1))
457
+ .sort(),
458
+ };
459
+ return {
460
+ key: d.key,
461
+ data: d.data,
462
+ version: d.version,
463
+ createdAt: new Date(d.createdAt).toISOString(),
464
+ updatedAt: new Date(d.updatedAt).toISOString(),
465
+ author,
466
+ counters,
467
+ marks,
468
+ viewer: mine,
469
+ };
470
+ }
471
+ function getMany(ctx, coll, keys) {
472
+ if (keys.length > B.getManyMax)
473
+ throw invalid(`getMany takes at most ${B.getManyMax} keys`);
474
+ keys.forEach(assertKey);
475
+ if (keys.length === 0)
476
+ return [];
477
+ const scope = scopeOf(ctx, coll);
478
+ storageOn();
479
+ return keys.map((k) => {
480
+ const d = docs.get(docId(ctx, coll, scope, k));
481
+ return d ? present(ctx, coll, d) : null;
482
+ });
483
+ }
484
+ function writeDoc(ctx, coll, key, data, ifVersion) {
485
+ const decl = declOf(coll);
486
+ assertKey(key);
487
+ const viewer = requireViewer(ctx);
488
+ assertShareWritable(decl);
489
+ if (ifVersion !== undefined && !(Number.isSafeInteger(ifVersion) && ifVersion >= 0)) {
490
+ throw invalid('ifVersion must be a non-negative integer');
491
+ }
492
+ const doc = serialize(data);
493
+ const index = indexRows(decl, doc);
494
+ const scope = scopeOf(ctx, coll);
495
+ storageOn();
496
+ const id = docId(ctx, coll, scope, key);
497
+ const existing = docs.get(id);
498
+ const size = storedSize(doc);
499
+ const tooLarge = () => refuse('too_large', `document exceeds ${B.docBytes} bytes`, { limit: B.docBytes });
500
+ const ceiling = decl.visibility === 'private' ? 'viewer' : 'author';
501
+ const t = now();
502
+ if (existing) {
503
+ if (ifVersion !== undefined && ifVersion !== existing.version) {
504
+ throw refuse('version_conflict', 'version conflict', { currentVersion: existing.version });
505
+ }
506
+ if (decl.visibility === 'shared' && !(existing.authorKind === 'user' && existing.authorUid === ctx.uid)) {
507
+ throw refuse('not_author', 'only the author may change this document');
508
+ }
509
+ if (size > B.docBytes)
510
+ throw tooLarge();
511
+ const commit = chargeUsage(ctx, viewer, size - existing.size, 0, ceiling);
512
+ commit();
513
+ Object.assign(existing, { data: doc, size, version: existing.version + 1, updatedAt: t, index });
514
+ return present(ctx, coll, existing);
515
+ }
516
+ if (ifVersion !== undefined && ifVersion !== 0) {
517
+ throw refuse('version_conflict', 'version conflict', { currentVersion: 0 });
518
+ }
519
+ if (size > B.docBytes)
520
+ throw tooLarge();
521
+ chargeUsage(ctx, viewer, size, 1, ceiling)();
522
+ const row = {
523
+ key,
524
+ data: doc,
525
+ size,
526
+ version: 1,
527
+ createdAt: t,
528
+ updatedAt: t,
529
+ authorUid: viewer,
530
+ authorKind: 'user',
531
+ index,
532
+ counters: new Map(),
533
+ markCounts: new Map(),
534
+ marks: new Set(),
535
+ };
536
+ docs.set(id, row);
537
+ return present(ctx, coll, row);
538
+ }
539
+ function remove(ctx, coll, key, ifVersion) {
540
+ const decl = declOf(coll);
541
+ assertKey(key);
542
+ const viewer = requireViewer(ctx);
543
+ assertShareWritable(decl);
544
+ const scope = scopeOf(ctx, coll);
545
+ storageOn();
546
+ const id = docId(ctx, coll, scope, key);
547
+ const row = docs.get(id);
548
+ if (!row) {
549
+ if (ifVersion !== undefined && ifVersion !== 0)
550
+ throw refuse('version_conflict', 'version conflict', { currentVersion: 0 });
551
+ return { deleted: false };
552
+ }
553
+ if (ifVersion !== undefined && ifVersion !== row.version) {
554
+ throw refuse('version_conflict', 'version conflict', { currentVersion: row.version });
555
+ }
556
+ if (decl.visibility === 'shared' && !(row.authorKind === 'user' && row.authorUid === ctx.uid)) {
557
+ throw refuse('not_author', 'only the author may change this document');
558
+ }
559
+ chargeUsage(ctx, viewer, -row.size, -1, decl.visibility === 'private' ? 'viewer' : 'author')();
560
+ docs.delete(id);
561
+ return { deleted: true };
562
+ }
563
+ function increment(ctx, coll, key, counter, by = 1) {
564
+ const decl = declOf(coll);
565
+ assertKey(key);
566
+ requireShared(decl, 'counters');
567
+ if (!TALLY_NAME.test(counter) || !decl.counters.includes(counter)) {
568
+ throw invalid(`counter ${counter} is not declared`, { counter });
569
+ }
570
+ if (!Number.isSafeInteger(by) || by < 1 || by > B.incrementMax) {
571
+ throw invalid(`by must be an integer from 1 to ${B.incrementMax}`);
572
+ }
573
+ const viewer = requireViewer(ctx);
574
+ storageOn();
575
+ const id = docId(ctx, coll, 0, key);
576
+ let row = docs.get(id);
577
+ if (!row) {
578
+ if ((decl.write ?? 'own') !== 'none')
579
+ throw refuse('not_found', 'document not found');
580
+ const t = now();
581
+ const size = storedSize({});
582
+ chargeUsage(ctx, viewer, size, 1, 'viewer')();
583
+ row = {
584
+ key,
585
+ data: {},
586
+ size,
587
+ version: 1,
588
+ createdAt: t,
589
+ updatedAt: t,
590
+ authorUid: viewer,
591
+ authorKind: 'counter',
592
+ index: new Map(),
593
+ counters: new Map(),
594
+ markCounts: new Map(),
595
+ marks: new Set(),
596
+ };
597
+ docs.set(id, row);
598
+ }
599
+ const value = (row.counters.get(counter) ?? 0) + by;
600
+ row.counters.set(counter, value);
601
+ return { value };
602
+ }
603
+ function assertMarkName(decl, name) {
604
+ requireShared(decl, 'marks');
605
+ if (name === REPORT)
606
+ return;
607
+ if (!TALLY_NAME.test(name) || !decl.marks.includes(name))
608
+ throw invalid(`mark ${name} is not declared`, { mark: name });
609
+ }
610
+ function setMark(ctx, coll, key, name, on) {
611
+ const decl = declOf(coll);
612
+ assertKey(key);
613
+ assertMarkName(decl, name);
614
+ const viewer = requireViewer(ctx);
615
+ storageOn();
616
+ const row = docs.get(docId(ctx, coll, 0, key));
617
+ if (!row)
618
+ throw refuse('not_found', 'document not found');
619
+ const id = `${viewer}:${name}`;
620
+ const had = row.marks.has(id);
621
+ if (on && !had) {
622
+ row.marks.add(id);
623
+ row.markCounts.set(name, (row.markCounts.get(name) ?? 0) + 1);
624
+ }
625
+ else if (!on && had) {
626
+ row.marks.delete(id);
627
+ row.markCounts.set(name, (row.markCounts.get(name) ?? 0) - 1);
628
+ }
629
+ return { marked: on, count: row.markCounts.get(name) ?? 0 };
630
+ }
631
+ function parseSort(decl, raw) {
632
+ const s = raw ?? 'key';
633
+ const desc = s.startsWith('-');
634
+ const field = desc ? s.slice(1) : s;
635
+ if (field === 'key')
636
+ return { kind: 'key', desc };
637
+ if (field === 'createdAt' || field === 'updatedAt')
638
+ return { kind: field, desc };
639
+ const tally = /^(counter|mark):(.+)$/.exec(field);
640
+ if (tally) {
641
+ requireShared(decl, 'counters and marks');
642
+ const kind = tally[1];
643
+ const name = tally[2];
644
+ const declared = kind === 'counter' ? decl.counters : decl.marks;
645
+ if (!declared.includes(name) || (kind === 'mark' && name === REPORT)) {
646
+ throw refuse('index_not_declared', `${kind} ${name} is not declared`, { field });
647
+ }
648
+ return { kind: 'tally', desc, tally: kind, name };
649
+ }
650
+ const type = decl.indexes.get(field);
651
+ if (!type)
652
+ throw refuse('index_not_declared', `sort field ${field} is not declared`, { field });
653
+ return { kind: 'index', desc, name: field, type };
654
+ }
655
+ /** The value a document sorts by; `undefined` = not in this ordering. */
656
+ function sortValue(sort, d) {
657
+ switch (sort.kind) {
658
+ case 'key':
659
+ return '';
660
+ case 'createdAt':
661
+ return d.createdAt;
662
+ case 'updatedAt':
663
+ return d.updatedAt;
664
+ case 'tally':
665
+ return (sort.tally === 'counter' ? d.counters : d.markCounts).get(sort.name) ?? 0;
666
+ case 'index': {
667
+ const row = d.index.get(sort.name);
668
+ if (!row || row.type !== sort.type)
669
+ return undefined;
670
+ return sort.type === 'string' ? row.text : row.num;
671
+ }
672
+ }
673
+ }
674
+ function list(ctx, coll, q) {
675
+ const decl = declOf(coll);
676
+ const limit = q.limit ?? B.listLimitDefault;
677
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > B.listLimitMax)
678
+ throw invalid(`limit must be 1–${B.listLimitMax}`);
679
+ const scope = scopeOf(ctx, coll);
680
+ const sort = parseSort(decl, q.sort);
681
+ let rows = docsIn(ctx, coll, scope);
682
+ const where = Object.entries(q.where ?? {});
683
+ if (where.length > B.equalityFilters)
684
+ throw invalid(`at most ${B.equalityFilters} equality filter`);
685
+ for (const [field, raw] of where) {
686
+ const type = decl.indexes.get(field);
687
+ if (!type)
688
+ throw refuse('index_not_declared', `${field} is not a declared index`, { field });
689
+ const want = indexValue(type, raw, field);
690
+ rows = rows.filter((d) => {
691
+ const row = d.index.get(field);
692
+ return !!row && row.type === type && (type === 'string' ? row.text === want.text : row.num === want.num);
693
+ });
694
+ }
695
+ if (q.keyPrefix !== undefined) {
696
+ if (!/^[A-Za-z0-9._:-]{0,128}$/.test(q.keyPrefix)) {
697
+ throw invalid('keyPrefix must be at most 128 characters of [A-Za-z0-9._:-]');
698
+ }
699
+ const prefix = q.keyPrefix;
700
+ rows = rows.filter((d) => d.key.startsWith(prefix));
701
+ }
702
+ if (q.author !== undefined) {
703
+ if (q.author !== 'me')
704
+ throw invalid('author must be "me"');
705
+ const me = requireViewer(ctx);
706
+ rows = rows.filter((d) => d.authorKind === 'user' && d.authorUid === me);
707
+ }
708
+ if (q.markedBy !== undefined || q.mark !== undefined) {
709
+ if (q.markedBy !== 'me' || q.mark === undefined)
710
+ throw invalid('markedBy=me and mark are required together');
711
+ assertMarkName(decl, q.mark);
712
+ const id = `${requireViewer(ctx)}:${q.mark}`;
713
+ rows = rows.filter((d) => d.marks.has(id));
714
+ }
715
+ const keyed = rows
716
+ .map((d) => ({ d, v: sortValue(sort, d) }))
717
+ .filter((x) => x.v !== undefined);
718
+ const dir = sort.desc ? -1 : 1;
719
+ const cmp = (a, b) => typeof a === 'string' && typeof b === 'string' ? byCodePoint(a, b) : a < b ? -1 : a > b ? 1 : 0;
720
+ const order = (a, b) => (cmp(a.v, b.v) || cmp(a.key, b.key)) * dir;
721
+ let ordered = keyed.map((x) => ({ d: x.d, v: x.v, key: x.d.key })).sort(order);
722
+ // OPAQUE, and bound to the query that issued it: a token for a stored position,
723
+ // refused (`invalid_request`) for any other collection, sort or filter.
724
+ // (Stricter than the server, which binds a cursor to its sort only.)
725
+ const binding = JSON.stringify([
726
+ coll,
727
+ q.sort ?? 'key',
728
+ q.where ?? {},
729
+ q.keyPrefix ?? null,
730
+ q.author ?? null,
731
+ q.markedBy ?? null,
732
+ q.mark ?? null,
733
+ ]);
734
+ if (q.cursor !== undefined) {
735
+ const cur = cursors.get(q.cursor);
736
+ if (!cur || cur.binding !== binding)
737
+ throw invalid('invalid cursor');
738
+ const bound = { v: cur.value, key: cur.key };
739
+ ordered = ordered.filter((x) => order(x, bound) > 0);
740
+ }
741
+ storageOn();
742
+ const page = ordered.slice(0, limit);
743
+ const last = page[page.length - 1];
744
+ return {
745
+ items: page.map((x) => present(ctx, coll, x.d)),
746
+ nextCursor: ordered.length > limit && last ? issueCursor(binding, last.v, last.key) : null,
747
+ };
748
+ }
749
+ function storageOn() {
750
+ if (options.disabled)
751
+ throw refuse('storage_disabled', 'document storage is unavailable');
752
+ }
753
+ function match(method, s) {
754
+ const [a, b, c, d] = s;
755
+ const pick = (table) => table[method] ? { route: table[method] } : { methods: Object.keys(table) };
756
+ switch (s.length) {
757
+ case 0:
758
+ return pick({ GET: { op: 'info' } });
759
+ case 1:
760
+ if (a.startsWith('_'))
761
+ return null;
762
+ return pick({ GET: { op: 'list', collection: a }, POST: { op: 'create', collection: a } });
763
+ case 2:
764
+ if (a === '_get')
765
+ return pick({ POST: { op: 'getMany', collection: b } });
766
+ if (a.startsWith('_'))
767
+ return null;
768
+ return pick({
769
+ GET: { op: 'get', collection: a, key: b },
770
+ PUT: { op: 'put', collection: a, key: b },
771
+ DELETE: { op: 'delete', collection: a, key: b },
772
+ });
773
+ case 4:
774
+ if (a.startsWith('_'))
775
+ return null;
776
+ if (c === 'counters')
777
+ return pick({ POST: { op: 'increment', collection: a, key: b, name: d } });
778
+ if (c === 'marks') {
779
+ return pick({
780
+ PUT: { op: 'mark', collection: a, key: b, name: d },
781
+ DELETE: { op: 'unmark', collection: a, key: b, name: d },
782
+ });
783
+ }
784
+ return null;
785
+ default:
786
+ return null;
787
+ }
788
+ }
789
+ function parseListQuery(query, decl) {
790
+ const q = {};
791
+ for (const [name, raw] of Object.entries(query)) {
792
+ if (Array.isArray(raw))
793
+ throw invalid(`${name} must be given once`);
794
+ const where = /^where\[([^\]]+)\]$/.exec(name);
795
+ if (where) {
796
+ const field = where[1];
797
+ let value = raw;
798
+ const type = decl.indexes.get(field);
799
+ if (type === 'number') {
800
+ if (raw.trim() === '' || !Number.isFinite(Number(raw)))
801
+ throw invalid(`where[${field}] must be a number`);
802
+ value = Number(raw);
803
+ }
804
+ else if (type === 'boolean') {
805
+ if (raw !== 'true' && raw !== 'false')
806
+ throw invalid(`where[${field}] must be true or false`);
807
+ value = raw === 'true';
808
+ }
809
+ else if (type === 'time' && /^-?[0-9]+$/.test(raw)) {
810
+ value = Number(raw);
811
+ }
812
+ q.where = { ...q.where, [field]: value };
813
+ continue;
814
+ }
815
+ if (!['sort', 'author', 'markedBy', 'mark', 'keyPrefix', 'limit', 'cursor'].includes(name)) {
816
+ throw invalid(`unknown query parameter ${name}`);
817
+ }
818
+ if (name === 'limit') {
819
+ if (!/^[0-9]{1,3}$/.test(raw))
820
+ throw invalid('limit must be an integer');
821
+ q.limit = Number(raw);
822
+ }
823
+ else if (name === 'author' || name === 'markedBy') {
824
+ if (raw !== 'me')
825
+ throw invalid(`${name} must be "me"`);
826
+ q[name] = 'me';
827
+ }
828
+ else {
829
+ q[name] = raw;
830
+ }
831
+ }
832
+ return q;
833
+ }
834
+ /** A strict body: an object holding only `allowed` keys, each passing its check. */
835
+ function parseBody(body, allowed, required = []) {
836
+ const value = body === undefined ? {} : body;
837
+ const issues = [];
838
+ if (!isObject(value))
839
+ issues.push({ path: '', message: 'expected an object' });
840
+ else {
841
+ for (const k of Object.keys(value)) {
842
+ if (!(k in allowed))
843
+ issues.push({ path: k, message: 'unrecognized key' });
844
+ else if (value[k] !== undefined && !allowed[k](value[k]))
845
+ issues.push({ path: k, message: 'invalid value' });
846
+ }
847
+ for (const k of required)
848
+ if (value[k] === undefined)
849
+ issues.push({ path: k, message: 'required' });
850
+ }
851
+ if (issues.length > 0)
852
+ throw invalid('invalid request body', { issues });
853
+ return value;
854
+ }
855
+ const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
856
+ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
857
+ /** Every string leaf and object key, plus the document key when the app chose it. */
858
+ function moderateWrite(input) {
859
+ const fields = [];
860
+ if (input.data !== undefined) {
861
+ const texts = [];
862
+ const walk = (v) => {
863
+ if (typeof v === 'string')
864
+ texts.push(v);
865
+ else if (Array.isArray(v))
866
+ v.forEach(walk);
867
+ else if (isObject(v)) {
868
+ for (const [k, x] of Object.entries(v)) {
869
+ texts.push(k);
870
+ walk(x);
871
+ }
872
+ }
873
+ };
874
+ walk(input.data);
875
+ fields.push(texts);
876
+ }
877
+ if (input.key !== undefined)
878
+ fields.push([input.key]);
879
+ for (const texts of fields) {
880
+ const verdicts = texts.map(moderate);
881
+ if (verdicts.includes('unavailable')) {
882
+ throw refuse('moderation_unavailable', 'content could not be reviewed right now', { retryAfter: 5 });
883
+ }
884
+ const hit = verdicts.includes('link') ? 'link' : verdicts.includes('blocked') ? 'blocked' : null;
885
+ if (hit)
886
+ throw refuse('content_rejected', 'content was rejected by moderation', { category: hit });
887
+ }
888
+ }
889
+ function assertTrusted(viewer) {
890
+ if (viewer.trusted === false) {
891
+ throw refuse('not_eligible', 'this account cannot write shared data yet', {
892
+ reason: viewer.untrustedReason ?? 'account_too_new',
893
+ });
894
+ }
895
+ }
896
+ /** The server's refusals that are ITS fault: refunded without a rejection charge. */
897
+ const isOurFault = (err) => !(err instanceof Refusal) ||
898
+ (err.code === 'rate_limited' ? err.details?.reason === 'contention' : ['storage_disabled', 'moderation_unavailable', 'consent_unavailable'].includes(err.code));
899
+ function handle(caller, method, path, query, sent) {
900
+ const segments = path.replace(/^\/+/, '').split('/').filter(Boolean);
901
+ if (segments[0] !== 'blocks' || segments[1] !== 'storage') {
902
+ throw new Error(`fake storage: no route for ${path}`);
903
+ }
904
+ let rest;
905
+ try {
906
+ rest = segments.slice(2).map((s) => decodeURIComponent(s));
907
+ }
908
+ catch {
909
+ throw refuse('not_found', 'no such storage route');
910
+ }
911
+ // The wire: only POST and PUT bodies are read, and only what JSON carries.
912
+ const body = (method === 'POST' || method === 'PUT') && sent !== undefined
913
+ ? JSON.parse(JSON.stringify(sent))
914
+ : undefined;
915
+ const viewer = caller.viewer === undefined ? defaultViewer : caller.viewer;
916
+ // The token middleware: its refusals come first, whatever the path.
917
+ if (caller.token === 'none')
918
+ throw refuse('unauthenticated', 'a block token is required');
919
+ if ((options.appStatus ?? 'approved') !== 'approved') {
920
+ throw refuse('not_eligible', 'app block is not approved', { reason: 'app_not_approved' });
921
+ }
922
+ const consent = options.consent;
923
+ const consentUnavailable = consent === 'unavailable';
924
+ const revoked = consent === undefined || consent === 'unavailable' ? [] : consent.revoked;
925
+ const scopes = (caller.scopes ?? options.manifest.scopes ?? []).filter((s) => !(CONSENT_GATED.has(s) && (consentUnavailable || revoked.includes(s))));
926
+ if (!scopes.includes(BASE_SCOPE))
927
+ throw refuse('insufficient_scope', `${BASE_SCOPE} scope required`);
928
+ const m = match(method, rest);
929
+ if (!m)
930
+ throw refuse('not_found', 'no such storage route');
931
+ if ('methods' in m)
932
+ throw refuse('method_not_allowed', 'method not allowed');
933
+ const route = m.route;
934
+ const allowedQuery = route.op === 'list' ? null : route.op === 'delete' ? ['ifVersion'] : [];
935
+ if (allowedQuery) {
936
+ const stray = Object.keys(query).find((k) => !allowedQuery.includes(k));
937
+ if (stray)
938
+ throw invalid(`unknown query parameter ${stray}`);
939
+ }
940
+ const uid = viewer === null ? null : uidOf(viewer);
941
+ if (caller.token === 'dev' && uid === null)
942
+ throw refuse('sign_in_required', 'sign in required');
943
+ const ctx = { ns: caller.token === 'dev' ? `dev:${uid}` : 'prod', uid };
944
+ if (body !== undefined && utf8Length(JSON.stringify(body)) > BODY_LIMIT_BYTES) {
945
+ throw refuse('too_large', `request body exceeds ${BODY_LIMIT_BYTES} bytes`, { limit: BODY_LIMIT_BYTES });
946
+ }
947
+ const requireSignedIn = () => {
948
+ if (uid === null)
949
+ throw refuse('sign_in_required', 'sign in required');
950
+ return uid;
951
+ };
952
+ if (route.op === 'info')
953
+ requireSignedIn();
954
+ if (route.op === 'info' || route.op === 'list' || route.op === 'get' || route.op === 'getMany')
955
+ charge('read', uid);
956
+ if (route.op === 'info') {
957
+ storageOn();
958
+ const app = appUsage.get(ctx.ns) ?? { bytes: 0, docs: 0 };
959
+ const mine = userUsage.get(`${ctx.ns}\u0000${uid}`) ?? { bytes: 0, docs: 0 };
960
+ return {
961
+ status: 200,
962
+ body: {
963
+ collections: Object.fromEntries([...collections].map(([name, d]) => [
964
+ name,
965
+ {
966
+ kind: 'docs',
967
+ visibility: d.visibility,
968
+ ...(d.visibility === 'shared' ? { write: d.write ?? 'own' } : {}),
969
+ counters: [...d.counters],
970
+ marks: [...d.marks],
971
+ indexes: Object.fromEntries(d.indexes),
972
+ showAuthor: d.showAuthor,
973
+ },
974
+ ])),
975
+ limits: {
976
+ docBytes: B.docBytes,
977
+ appBytes: limits.appBytes,
978
+ appDocs: limits.appDocs,
979
+ viewerBytes: limits.viewerBytes,
980
+ viewerDocs: limits.viewerDocs,
981
+ listLimitMax: B.listLimitMax,
982
+ getManyMax: B.getManyMax,
983
+ rate: {
984
+ readsPerMinute: RATE.read.max,
985
+ privateWritesPerMinute: RATE.write.max,
986
+ marksAndCountersPerMinute: RATE.tally.max,
987
+ reportsPerDay: RATE.report.max,
988
+ sharedWritesBurst: SHARED_WRITE_BUDGET[0][0],
989
+ sharedWritesPerDay: SHARED_WRITE_BUDGET[1][0],
990
+ },
991
+ },
992
+ usage: { app: { ...app }, viewer: { ...mine } },
993
+ },
994
+ };
995
+ }
996
+ if (!COLLECTION_NAME.test(route.collection) || !collections.has(route.collection)) {
997
+ throw refuse('collection_not_declared', `collection ${route.collection} is not declared`, { collection: route.collection });
998
+ }
999
+ const coll = route.collection;
1000
+ const decl = declOf(coll);
1001
+ const shared = decl.visibility === 'shared';
1002
+ switch (route.op) {
1003
+ case 'list':
1004
+ return { status: 200, body: list(ctx, coll, parseListQuery(query, decl)) };
1005
+ case 'get': {
1006
+ const [doc] = getMany(ctx, coll, [route.key]);
1007
+ if (!doc)
1008
+ throw refuse('not_found', 'document not found');
1009
+ return { status: 200, body: doc };
1010
+ }
1011
+ case 'getMany': {
1012
+ const { keys } = parseBody(body, { keys: isStringArray }, ['keys']);
1013
+ return { status: 200, body: { items: getMany(ctx, coll, keys) } };
1014
+ }
1015
+ case 'create':
1016
+ case 'put':
1017
+ case 'delete': {
1018
+ const me = requireSignedIn();
1019
+ assertShareWritable(decl);
1020
+ if (route.op !== 'delete' && shared && decl.write === 'own' && !scopes.includes(PUBLISH_SCOPE)) {
1021
+ if (consentUnavailable) {
1022
+ throw refuse('consent_unavailable', 'permission state is temporarily unavailable', { retryAfter: 1 });
1023
+ }
1024
+ throw refuse('consent_required', `${PUBLISH_SCOPE} has not been granted`, { scope: PUBLISH_SCOPE });
1025
+ }
1026
+ const created = route.op === 'create' ? parseBody(body, { data: isObject }, ['data']) : null;
1027
+ const putIn = route.op === 'put' ? parseBody(body, { data: isObject, ifVersion: isNumber }, ['data']) : null;
1028
+ let deleteVersion;
1029
+ if (route.op === 'delete' && query.ifVersion !== undefined) {
1030
+ const raw = query.ifVersion;
1031
+ if (Array.isArray(raw) || !/^[0-9]{1,15}$/.test(raw))
1032
+ throw invalid('ifVersion must be a non-negative integer');
1033
+ deleteVersion = Number(raw);
1034
+ }
1035
+ const write = (moderated, landed) => {
1036
+ if (created) {
1037
+ if (moderated)
1038
+ moderateWrite({ data: created.data });
1039
+ const doc = writeDoc(ctx, coll, newKey(), created.data, 0);
1040
+ landed();
1041
+ return { status: 201, body: doc };
1042
+ }
1043
+ if (putIn && route.op === 'put') {
1044
+ if (moderated)
1045
+ moderateWrite({ data: putIn.data, key: route.key });
1046
+ const doc = writeDoc(ctx, coll, route.key, putIn.data, putIn.ifVersion);
1047
+ landed();
1048
+ return { status: 200, body: doc };
1049
+ }
1050
+ if (route.op !== 'delete')
1051
+ throw new Error('fake storage: unreachable write op');
1052
+ return { status: 200, body: remove(ctx, coll, route.key, deleteVersion) };
1053
+ };
1054
+ if (!shared || route.op === 'delete') {
1055
+ charge('write', uid);
1056
+ return write(false, () => undefined);
1057
+ }
1058
+ assertTrusted(viewer);
1059
+ const release = reserveSharedWrite(me);
1060
+ let hasLanded = false;
1061
+ try {
1062
+ return write(true, () => {
1063
+ hasLanded = true;
1064
+ });
1065
+ }
1066
+ catch (err) {
1067
+ if (!hasLanded)
1068
+ release(!isOurFault(err));
1069
+ throw err;
1070
+ }
1071
+ }
1072
+ case 'increment': {
1073
+ requireSignedIn();
1074
+ const { by } = parseBody(body, { by: isNumber });
1075
+ charge('tally', uid);
1076
+ if (shared)
1077
+ assertTrusted(viewer);
1078
+ if (shared && decl.write === 'none')
1079
+ moderateWrite({ key: route.key });
1080
+ return { status: 200, body: increment(ctx, coll, route.key, route.name, by) };
1081
+ }
1082
+ case 'mark':
1083
+ case 'unmark': {
1084
+ // A report cannot be withdrawn (withdraw + re-file would re-open a document a
1085
+ // moderator dismissed). The server refuses it FIRST — before sign-in, any budget
1086
+ // or the trust lookup — so it is a 400 for everyone and costs nothing.
1087
+ if (route.op === 'unmark' && route.name === REPORT) {
1088
+ throw invalid('a report cannot be withdrawn', { mark: route.name });
1089
+ }
1090
+ requireSignedIn();
1091
+ if (route.op === 'mark')
1092
+ parseBody(body, {});
1093
+ charge('tally', uid);
1094
+ if (route.op === 'mark' && route.name === REPORT)
1095
+ charge('report', uid);
1096
+ if (shared)
1097
+ assertTrusted(viewer);
1098
+ const result = setMark(ctx, coll, route.key, route.name, route.op === 'mark');
1099
+ return { status: 200, body: route.name === REPORT ? { marked: result.marked } : result };
1100
+ }
1101
+ }
1102
+ }
1103
+ /** A sortable server-chosen key, ULID-shaped: time, then a per-fake sequence. */
1104
+ let seq = 0;
1105
+ const newKey = () => `${now().toString(36).padStart(10, '0')}${(seq++).toString(36).padStart(16, '0')}`.toUpperCase();
1106
+ function session(caller) {
1107
+ const resolved = { ...caller, token: caller.token ?? 'block' };
1108
+ const http = (async (method, path, opts = {}) => {
1109
+ // The query as a URL carries it: null/undefined dropped, everything a string.
1110
+ const query = {};
1111
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
1112
+ const values = (Array.isArray(v) ? v : [v]).filter((x) => x != null).map(String);
1113
+ if (values.length === 1)
1114
+ query[k] = values[0];
1115
+ else if (values.length > 1)
1116
+ query[k] = values;
1117
+ }
1118
+ const call = { method, path, query, body: opts.body, status: 0, response: undefined };
1119
+ calls.push(call);
1120
+ try {
1121
+ const reply = handle(resolved, method, path, query, opts.body);
1122
+ call.status = reply.status;
1123
+ call.response = JSON.parse(JSON.stringify(reply.body));
1124
+ return call.response;
1125
+ }
1126
+ catch (err) {
1127
+ if (!(err instanceof Refusal))
1128
+ throw err;
1129
+ const response = { code: err.code, message: err.message, error: err.message };
1130
+ if (err.details)
1131
+ response.details = err.details;
1132
+ call.status = STATUS[err.code] ?? 500;
1133
+ call.response = response;
1134
+ throw new ApiError(call.status, err.message, response);
1135
+ }
1136
+ });
1137
+ return { http, storage: createDocumentStorage(http) };
1138
+ }
1139
+ const own = session({});
1140
+ return { ...own, as: session, calls };
1141
+ }