@civitai/sdk 0.13.1 → 0.14.1

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/BREAKING.md CHANGED
@@ -204,8 +204,8 @@ bridge already called, so behaviour is identical on both transports.
204
204
  - **Anonymous viewers may NEVER write or vote.** An anon write is rejected by the write scope's own binding in
205
205
  `withBlockScope`, before the handler runs, so it surfaces as **`403`** rather than the handler's own `401`.
206
206
  - **Authenticated writers must clear a minimum-trust gate.** After the anon check, writes call
207
- `assertSharedWriteTrust` — account age, paid tier, and verified email *or* a linked OAuth account. A signed-in
208
- viewer is not automatically a permitted writer.
207
+ `assertSharedWriteTrust` — account age, completed onboarding, not banned or muted, and verified email *or* a
208
+ linked OAuth account. A signed-in viewer is not automatically a permitted writer.
209
209
  - **Tenancy is structural, not checked.** Every query interpolates a schema name derived from the **verified
210
210
  token**, never from client input. An app cannot address another app's shared storage because it cannot name it.
211
211
  - **Rate limits are per-operation and per-namespace**, so one operation cannot starve another — browsing a feed
package/README.md CHANGED
@@ -185,15 +185,106 @@ Routes are addressed by path, so a route `/api/v1` gains needs no release here:
185
185
  const images = await app.site.get('images', { query: { limit: 20, username: 'civitai' } });
186
186
  ```
187
187
 
188
- A `401` is retried once with a fresh token. Any failure the server answered is an
188
+ A `401` is retried once with a fresh token — except one whose body's `code` is
189
+ `sign_in_required`, which is about the viewer, not the token. Any failure the server answered is an
189
190
  `ApiError` carrying `status`, the parsed `body`, and the server's own message —
190
191
  while a request the browser never delivered (CORS, DNS, offline) surfaces as a
191
192
  fetch `TypeError` with no `status`, so test `error instanceof ApiError` before
192
193
  reading one.
193
194
 
195
+ ## Document storage
196
+
197
+ An app declares its **collections** in its manifest, and `app.storage.collection(name)`
198
+ reads and writes their JSON documents. `private` collections hold one key space per
199
+ viewer; `shared` ones one key space every viewer reads, written under the policy the
200
+ collection declares (`own`: viewers create, only the author edits or deletes;
201
+ `none`: nobody writes documents, viewers only increment counters and set marks).
202
+
203
+ ```json
204
+ "scopes": ["apps:storage", "apps:storage:publish"],
205
+ "storage": { "collections": {
206
+ "prefs": { "visibility": "private" },
207
+ "requests": { "visibility": "shared", "write": "own", "marks": ["vote"],
208
+ "indexes": { "status": "string" }, "showAuthor": true },
209
+ "plays": { "visibility": "shared", "write": "none", "counters": ["plays"] }
210
+ }}
211
+ ```
212
+
213
+ ```ts
214
+ interface Request {
215
+ title: string;
216
+ status: 'open' | 'done';
217
+ }
218
+
219
+ const requests = app.storage.collection<Request>('requests');
220
+
221
+ const created = await requests.create({ title: 'More lighting presets', status: 'open' });
222
+ const doc = await requests.get(created.key); // null when there is none
223
+ await requests.put(created.key, { title: 'More lighting presets', status: 'done' }, { ifVersion: doc?.version });
224
+ await requests.mark(created.key, 'vote'); // { marked: true, count }
225
+
226
+ const page = await requests.list({ where: { status: 'open' }, sort: '-mark:vote', limit: 20 });
227
+ for (const item of page.items) {
228
+ console.log(item.data.title, item.marks.vote, item.author, item.viewer?.marks);
229
+ }
230
+ const mine = await requests.list({ author: 'me' });
231
+ const voted = await requests.list({ markedBy: 'me', mark: 'vote' });
232
+
233
+ await app.storage.collection('plays').increment('model-123', 'plays'); // creates the doc in a `none` collection
234
+ const { limits, usage } = await app.storage.info();
235
+ ```
236
+
237
+ - **Documents** are `{ key, data, version, createdAt, updatedAt, author, counters,
238
+ marks, viewer }`. `createdAt`/`updatedAt` are `Date`s. `author` is
239
+ `{ kind: 'user', isViewer, username? }` (the username only in a `showAuthor`
240
+ collection), `{ kind: 'counter' }` for a document a counter created, or
241
+ `{ kind: 'app' }` — never a numeric user id. `viewer` is `null` for an anonymous
242
+ viewer.
243
+ - **Conditional writes:** `put(key, data, { ifVersion })` and
244
+ `delete(key, { ifVersion })` write only at that version (`0` = only if absent);
245
+ a mismatch rejects `version_conflict` with `details.currentVersion`.
246
+ - **Lists** take ONE equality on a declared index (`where`), one `sort`
247
+ (`[-]key|createdAt|updatedAt|<index>|counter:<n>|mark:<n>`), `author: 'me'`,
248
+ `markedBy: 'me'` with `mark`, `keyPrefix`, `limit` and `cursor`. `nextCursor` is
249
+ `null` exactly on the last page; pass it back with the same query.
250
+ - **A refusal in the storage envelope is a `StorageError`** (an `ApiError`) with
251
+ the server's `code`, `status` and `details`; `retryAfter` is set when the server
252
+ named a wait. So is a name or key the client refuses before sending (an invalid
253
+ collection name is `collection_not_declared`; a key of `''`, `.` or `..` is
254
+ `invalid_request`). Anything else arrives as it would from any route: a response
255
+ without the envelope (a proxy error page, a site without these routes) is a plain
256
+ `ApiError` with its `status`, a request that never got an answer (offline, CORS,
257
+ DNS) is the `fetch` `TypeError`, and a 2xx the client cannot read is a
258
+ `CivitaiError`. `get` reads only `not_found` as "no document". Branch on `code`:
259
+
260
+ ```ts
261
+ import { StorageError } from '@civitai/sdk';
262
+
263
+ try {
264
+ await requests.put('r1', { title: 'Renamed', status: 'open' }, { ifVersion: 3 });
265
+ } catch (error) {
266
+ if (error instanceof StorageError && error.code === 'version_conflict') reloadAndRetry();
267
+ else if (error instanceof StorageError && error.code === 'consent_required') askForPublishConsent();
268
+ else throw error;
269
+ }
270
+ ```
271
+
272
+ Anonymous viewers may read `shared` collections and nothing else
273
+ (`sign_in_required`). Creating or editing a document in a viewer-written shared
274
+ collection needs the consent-gated `apps:storage:publish`; deleting your own, marks
275
+ and counters need only `apps:storage`. Shared writes pass a minimum-trust gate and
276
+ moderation. Only phase-one operations exist: no batch writes, change feed, TTL or
277
+ owner-written documents yet.
278
+
279
+ The legacy stores below keep working, unchanged, while apps move; their methods are
280
+ `@deprecated`. Nothing is copied from them into collections.
281
+
194
282
  ## App storage
195
283
 
196
- `app.storage` is the viewer's own key/value store, scoped to this app and this
284
+ > **Deprecated** in favour of [document storage](#document-storage): declare a
285
+ > `private` collection and use `app.storage.collection(name)`.
286
+
287
+ `app.storage`'s `get`/`set`/`delete`/`list`/`getQuota` are the viewer's own key/value store, scoped to this app and this
197
288
  block instance. It needs the block token the host mints — an app holding an
198
289
  OAuth access token has no per-viewer app storage — and an anonymous viewer is
199
290
  refused, so gate on `app.viewer` rather than reading an empty page as "nothing
@@ -237,12 +328,15 @@ try {
237
328
 
238
329
  ## Shared storage
239
330
 
331
+ > **Deprecated** in favour of [document storage](#document-storage): declare a
332
+ > `shared` collection, which has authorship, counters and marks built in.
333
+
240
334
  `app.sharedStorage` is this app's **cross-user** key/value store — one namespace
241
335
  every viewer reads and writes, unlike `app.storage`, which is private per viewer.
242
336
  Five methods: `list`, `get`, `append`, `update`, `withdraw`.
243
337
 
244
338
  ```ts
245
- const { items, nextCursor } = await app.sharedStorage.list({ prefix: 'deck:' });
339
+ const { items, nextCursor } = await app.sharedStorage.list({ limit: 20 }); // newest first
246
340
  const { key } = await app.sharedStorage.append({ title: 'My deck', data: { cards } });
247
341
  const row = await app.sharedStorage.get(key); // null when hidden or absent
248
342
  await app.sharedStorage.update(key, { title: 'Renamed' });
@@ -268,9 +362,9 @@ other.** This is the opposite of `app.storage`, so do not carry that gate over:
268
362
  supported path — and may **never** write. An anon write is a `403` from the
269
363
  scope binding, not the `401` a missing token gives.
270
364
  - **A signed-in viewer is not automatically a permitted writer.** Writes clear a
271
- server-side minimum-trust gate (account age, paid tier, verified email or a
272
- linked OAuth account). Treat a write refusal as a normal outcome with a real
273
- message in the UI, not an unexpected error.
365
+ server-side minimum-trust gate (account age, completed onboarding, verified
366
+ email or a linked OAuth account, not banned or muted). Treat a write refusal
367
+ as a normal outcome with a real message in the UI, not an unexpected error.
274
368
 
275
369
  So gate the write affordances on `app.viewer`, and render the reads regardless.
276
370
 
@@ -285,7 +379,9 @@ that type-checks:
285
379
  `app.storage`'s, absence proves the scan completed.
286
380
  - **`append` takes no key.** The server mints a ULID and returns it, so one
287
381
  viewer cannot overwrite another's row. Use `update` for an in-place edit of a
288
- row this viewer authored.
382
+ row this viewer authored. It also means `list`'s `prefix` is no namespace of
383
+ yours: a prefix you choose (`'deck:'`) matches none of the rows `append`
384
+ creates. Put what you filter by in `data` and filter the page yourself.
289
385
  - **`value` is `unknown` on the way out.** The row was written by some other
290
386
  viewer's copy of your app, possibly an older version, so its shape is a fact
291
387
  about the data rather than a promise the client can make. Narrow it yourself.
@@ -788,6 +884,49 @@ entry.
788
884
  `@civitai/sdk/testing` has `createFakeTransport()`: pass it as
789
885
  `initialize({ transport })` and script the host's answers.
790
886
 
887
+ ### A fake document store
888
+
889
+ `createFakeStorage({ manifest })` is an in-memory document store behind the same
890
+ `Http` seam the client uses, enforcing the server's rules: scopes and consent, write
891
+ policies, the minimum-trust gate, anonymous readers, keys, limits and quotas,
892
+ moderation, error codes and statuses. This package runs the platform's own storage
893
+ contract against it, so where the contract speaks the fake and the server agree.
894
+
895
+ ```ts
896
+ import { createFakeStorage } from '@civitai/sdk/testing';
897
+
898
+ const fake = createFakeStorage({ manifest, viewer: { username: 'alice' } });
899
+ const prefs = fake.storage.collection('prefs'); // the real client, against the fake
900
+ await prefs.put('theme', { mode: 'dark' });
901
+
902
+ const bob = fake.as({ viewer: { username: 'bob', trusted: false } }).storage;
903
+ const anon = fake.as({ viewer: null }).storage;
904
+ ```
905
+
906
+ **Rate limits are OFF by default**, so your own tests are never throttled. Pass
907
+ `enforceRateLimits: true` to apply the server's — the per-minute and per-day windows
908
+ for reads, private writes and deletes, marks and counters, and reports, plus the
909
+ shared write budget — when you want to test how the app handles `rate_limited`
910
+ (drive the clock with `now`).
911
+
912
+ Where it differs from the server on purpose:
913
+
914
+ - A declaration using a feature the platform has not shipped — `write: "owner"` or
915
+ `"anyone"`, `kind: "log"`/`maxEntries`, `ttlSeconds`, `dailyWritesPerAuthor` —
916
+ makes `createFakeStorage` throw, naming it, instead of failing per request. It is
917
+ not a manifest validator otherwise; `civitai app validate` is.
918
+ - A list `nextCursor` is an opaque token, refused for any other collection, sort or
919
+ filter than the query that issued it. Treat it as opaque against the server too.
920
+
921
+ `fake.calls` records every request and answer. Options also cover smaller `limits`,
922
+ `consent` (revoked scopes, or `'unavailable'`), `appStatus`, `disabled` and a custom
923
+ `moderate`; by default the scanner refuses the words in `FAKE_STORAGE_MODERATION`. It
924
+ does not model moderator hides or the review-preview namespace, and at the `Http`
925
+ seam there is no URL resolution — the client refuses the segments a URL would
926
+ rewrite (`''`, `.`, `..`) before sending.
927
+
928
+ ### The legacy key/value stores
929
+
791
930
  There is no published fake for app storage, on purpose. The seam is `fetch`, so
792
931
  pass your own `fetch` to `initialize({ token, fetch })` and the client's URLs,
793
932
  bodies, statuses and date revival are all real — which a fake replacing the
@@ -4,6 +4,7 @@ import { type Host } from '../host/index.js';
4
4
  import { type OrchestrationClient } from '../orchestration/index.js';
5
5
  import { type GrantOptions, type Scope, type TokenOptions, type TokenSessionOptions } from '../session/index.js';
6
6
  import { type SharedStorageClient } from '../shared-storage/index.js';
7
+ import { type DocumentStorage } from '../doc-store/index.js';
7
8
  import { type SiteClient } from '../site/index.js';
8
9
  import { type StorageClient } from '../storage/index.js';
9
10
  import { type WorkflowsClient } from '../workflows/index.js';
@@ -20,15 +21,26 @@ export interface AppClient {
20
21
  */
21
22
  readonly site: SiteClient;
22
23
  /**
23
- * The viewer's own per-app key/value store.
24
+ * App storage. Two stores share this property while apps migrate:
24
25
  *
25
- * 🔴 Requires the block token the host mints: an app that authenticated with
26
- * an OAuth access token has no per-viewer app storage, and every call here is
27
- * refused. An anonymous viewer is refused too — gate on `viewer` rather than
28
- * reading an empty result as "nothing stored".
26
+ * - **The document store** — `collection(name)` and `info()`: the collections
27
+ * the app declares under `storage.collections` in its manifest, private or
28
+ * shared, with counters, marks, declared indexes and conditional writes.
29
+ * Use this for anything new.
30
+ * - **The legacy per-viewer key/value store** — `get`, `set`, `delete`, `list`,
31
+ * `getQuota`, each `@deprecated`. It keeps working, unchanged, until the apps
32
+ * that use it have moved; nothing is copied between the two.
33
+ *
34
+ * 🔴 Both require the block token the host mints: an app that authenticated
35
+ * with an OAuth access token is refused. The legacy store refuses an anonymous
36
+ * viewer outright; the document store lets one read SHARED collections only.
29
37
  */
30
- readonly storage: StorageClient;
38
+ readonly storage: StorageClient & DocumentStorage;
31
39
  /**
40
+ * @deprecated Declare a `shared` collection under `storage.collections` and use
41
+ * `app.storage.collection(name)`. This store keeps working, unchanged, until the
42
+ * apps that use it have moved.
43
+ *
32
44
  * This app's CROSS-USER store — one namespace every viewer of this app reads
33
45
  * and writes, which is what makes a shared feed or a "popular" rail possible.
34
46
  *
package/dist/app/index.js CHANGED
@@ -5,6 +5,7 @@ import { ApiError, createHttp } from '../http/index.js';
5
5
  import { createOrchestrationClient, DEFAULT_ORCHESTRATION_URL, } from '../orchestration/index.js';
6
6
  import { createTokenSession, } from '../session/index.js';
7
7
  import { createSharedStorageClient, } from '../shared-storage/index.js';
8
+ import { createDocumentStorage } from '../doc-store/index.js';
8
9
  import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
9
10
  import { createStorageClient } from '../storage/index.js';
10
11
  import { createWorkflowsClient } from '../workflows/index.js';
@@ -85,15 +86,19 @@ function createAppClient(session, options, holdsBlockTokenNow = () => false, hol
85
86
  // clients' routes live under the same base URL, so a `siteUrl` override
86
87
  // redirects them together.
87
88
  // Wrapped once, so `BLOCK_NAMESPACE` is the single place that decides where the
88
- // diagnosis applies. None of the three `blocks/*` clients needs an exemption
89
- // of its own: their routes are `blocks/app-storage/*`, `blocks/shared-storage/*`
90
- // and `blocks/workflows/*`, which that namespace already excludes, and
91
- // `test/storage/seam.test.ts`, `test/shared-storage/seam.test.ts` and
92
- // `test/workflows/seam.test.ts` pin the three `BASE` values there textually.
89
+ // diagnosis applies. None of the four `blocks/*` clients needs an exemption
90
+ // of its own: their routes are `blocks/app-storage/*`, `blocks/shared-storage/*`,
91
+ // `blocks/storage/*` and `blocks/workflows/*`, which that namespace already excludes, and
92
+ // `test/storage/seam.test.ts`, `test/shared-storage/seam.test.ts`,
93
+ // `test/doc-store/client.test.ts` and `test/workflows/seam.test.ts` pin the four
94
+ // `BASE` values there textually.
93
95
  const siteHttp = explainApiRefusal(http(options.siteUrl ?? DEFAULT_SITE_URL), holdsBlockTokenNow);
94
96
  return {
95
97
  site: createSiteClient(siteHttp),
96
- storage: createStorageClient(siteHttp),
98
+ // One object, two stores, no shared names: the legacy five and the document
99
+ // store's `collection`/`info`. Spread rather than chained so neither keeps a
100
+ // reference to the other.
101
+ storage: { ...createStorageClient(siteHttp), ...createDocumentStorage(siteHttp) },
97
102
  sharedStorage: createSharedStorageClient(siteHttp),
98
103
  workflows: createWorkflowsClient(explainWorkflowRefusal(siteHttp, holdsOAuthTokenNow)),
99
104
  orchestration: createOrchestrationClient(refuseBlockToken(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL), holdsBlockTokenNow)),
@@ -0,0 +1,94 @@
1
+ import { type Http } from '../http/index.js';
2
+ import { type DocumentStorage } from './index.js';
3
+ declare const DEFAULT_LIMITS: {
4
+ appBytes: number;
5
+ appDocs: number;
6
+ viewerBytes: number;
7
+ viewerDocs: number;
8
+ };
9
+ /** The default scanner's words: the contract's `conventions`, so a test can reach each refusal. */
10
+ export declare const FAKE_STORAGE_MODERATION: Readonly<{
11
+ blockedText: "contract-blocked-term";
12
+ blockedLink: "https://contract-blocked.example/x";
13
+ scannerDown: "contract-scanner-down";
14
+ }>;
15
+ export interface FakeStorageViewer {
16
+ /** The viewer's identity in this fake, and what `showAuthor` collections display. */
17
+ username: string;
18
+ /**
19
+ * The viewer's numeric user id, as the platform keys it. Internal: no response
20
+ * carries it, which a test can check by choosing a distinctive one. Default: a
21
+ * small number assigned on first use.
22
+ */
23
+ id?: number;
24
+ /** Passes the minimum-trust gate on shared writes. Default `true`. */
25
+ trusted?: boolean;
26
+ /** `details.reason` when untrusted. Default `account_too_new`. */
27
+ untrustedReason?: string;
28
+ }
29
+ /** Who is calling: a viewer (or `null`, anonymous) holding a token of some kind and scopes. */
30
+ export interface FakeStorageCaller {
31
+ /** Default: the fake's `viewer`. `null` is an anonymous viewer with a token. */
32
+ viewer?: FakeStorageViewer | null;
33
+ /** `block` (default), `dev` (the developer's own namespace) or `none` (no token at all). */
34
+ token?: 'block' | 'dev' | 'none';
35
+ /** The token's scopes. Default: the manifest's `scopes`. */
36
+ scopes?: readonly string[];
37
+ }
38
+ export type FakeModerationVerdict = 'blocked' | 'link' | 'unavailable' | null;
39
+ export interface FakeStorageOptions {
40
+ /** The app's manifest; `storage.collections` is what the fake enforces. */
41
+ manifest: {
42
+ scopes?: readonly string[];
43
+ storage?: unknown;
44
+ };
45
+ /** The default caller's viewer. Default `{ username: 'viewer' }`; `null` for anonymous. */
46
+ viewer?: FakeStorageViewer | null;
47
+ /** Default `approved`; anything else refuses every call `not_eligible`. */
48
+ appStatus?: string;
49
+ /** Consent state: scopes the viewer revoked, or `unavailable` (the marker could not be read). */
50
+ consent?: {
51
+ revoked: readonly string[];
52
+ } | 'unavailable';
53
+ /** `true` answers every data operation `storage_disabled`, as a server with no database does. */
54
+ disabled?: boolean;
55
+ /** Override the per-app and per-viewer ceilings, e.g. a small `viewerDocs` to reach the quota. */
56
+ limits?: Partial<typeof DEFAULT_LIMITS>;
57
+ /**
58
+ * Apply the server's rate limits: the per-minute and per-day windows (reads, private
59
+ * writes and deletes, marks and counters, reports) and the shared write budget.
60
+ * Default `false`, so an app's own tests are never throttled; turn it on to test how
61
+ * the app handles `rate_limited`.
62
+ */
63
+ enforceRateLimits?: boolean;
64
+ /** The clock rate limits and timestamps read. Default `Date.now`. */
65
+ now?: () => number;
66
+ /**
67
+ * Classify one string (a value, an object key or a document key) of a shared write.
68
+ * Default: {@link FAKE_STORAGE_MODERATION}'s words.
69
+ */
70
+ moderate?: (text: string) => FakeModerationVerdict;
71
+ }
72
+ /** One request as the fake received it, and what it answered. */
73
+ export interface FakeStorageCall {
74
+ method: string;
75
+ path: string;
76
+ query: Record<string, string | string[]>;
77
+ body: unknown;
78
+ status: number;
79
+ response: unknown;
80
+ }
81
+ export interface FakeStorageSession {
82
+ /** The `Http` seam, as this caller. */
83
+ readonly http: Http;
84
+ /** The real client over that seam. */
85
+ readonly storage: DocumentStorage;
86
+ }
87
+ export interface FakeStorage extends FakeStorageSession {
88
+ /** The same store, as a different caller. */
89
+ as(caller: FakeStorageCaller): FakeStorageSession;
90
+ /** Every request received, in order, by any caller. */
91
+ readonly calls: FakeStorageCall[];
92
+ }
93
+ export declare function createFakeStorage(options: FakeStorageOptions): FakeStorage;
94
+ export {};