@civitai/sdk 0.7.0 β†’ 0.8.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/BREAKING.md CHANGED
@@ -19,7 +19,7 @@ messages have no destination yet.
19
19
  | `SUBMIT_WORKFLOW`, `ESTIMATE_WORKFLOW`, `POLL_WORKFLOW`, `CANCEL_WORKFLOW`, `QUERY_APP_WORKFLOWS`, `CANCEL_APP_WORKFLOW` | `POST /api/v1/blocks/workflows/{submit,estimate,poll,cancel,query}` | **Routes exist.** πŸ”΄ Use these, **not** `app.orchestration` β€” see *What a direct orchestrator call loses* below |
20
20
  | `GET_IMAGES_BY_IDS` | `GET /api/v1/blocks/images?ids=1,2,3` | Batch, up to **100** ids per request. Misses are reported by OMISSION β€” see below. πŸ”΄ **Not `/api/v1/images`** β€” that is a `PublicEndpoint`; it ignores your token and answers with anonymous public results rather than erroring |
21
21
  | `APP_STORAGE_*` | `POST /api/v1/blocks/app-storage/*` | **Routes exist** β€” five of them (`get`, `set`, `delete`, `list`, `quota`), civitai#5085. This row said "No v1 route"; that is no longer true. See *App storage* below |
22
- | `SHARED_*` | `GET\|POST /api/v1/blocks/shared-storage/*` | **Routes exist** β€” eleven of them; see *Shared storage* below |
22
+ | `SHARED_*` | `app.sharedStorage`, over `GET\|POST /api/v1/blocks/shared-storage/*` | **Routes exist** β€” eleven of them. `app.sharedStorage` wraps the **five** key/value ones; the higher-level ops are deliberately app-layer. See *Shared storage* below |
23
23
  | `GET_BUZZ_BALANCE` | `GET /api/v1/blocks/buzz` | **Route exists.** Returns `{ blue, green, yellow }` β€” a bare object, three numbers |
24
24
  | `GET_BUZZ_ACCOUNTS`, `GET_BUZZ_TRANSACTIONS` | β€” | No v1 route |
25
25
  | `CREATE_POST_FROM_APP` | β€” | No v1 route, **and deliberately staying on the bridge** β€” see below |
@@ -57,6 +57,119 @@ first.
57
57
  Eleven routes under `/api/v1/blocks/shared-storage/`. Reads take `apps:storage:shared:read`, writes take
58
58
  `apps:storage:shared:write` β€” both scopes already existed; neither is new.
59
59
 
60
+ ### `app.sharedStorage` wraps FIVE of the eleven, on purpose
61
+
62
+ `app.sharedStorage` is a **generic cross-user key/value store**: `list`, `get` (the `/item` route, matching its
63
+ tRPC twin `apps.shared.get`), `append`, `update`, `withdraw`. An app no longer needs to hand-roll
64
+ `app.site.get('blocks/shared-storage/…')`, and the shape traps below are handled for it.
65
+
66
+ πŸ”΄ **`vote`, `unvote`, `counts`, `top`, `increment` and `report` are DELIBERATELY ABSENT from the client.**
67
+ The platform serves all eleven routes and will keep doing so β€” this is a surface decision, not a gap, and not
68
+ an oversight to be "fixed". Shared storage is scoped to generic key/value operations; the higher-level ops
69
+ belong at the app layer, where an app that wants voting, counters or reporting builds them on top of these
70
+ five. If demand shows up, the platform-side surface can be expanded again and the client can follow β€” but
71
+ **the routes existing is not a reason to add a method here.** Do not add one back without that decision being
72
+ revisited.
73
+
74
+ What survives the cut, and why: `SharedItem` still carries **`count` and `viewerVoted`** exactly as the two
75
+ read routes project them. Reading a tally is what makes an app-layer vote feature possible; only the
76
+ vote-casting operation is out of scope.
77
+
78
+ πŸ”΄ **It is not the per-viewer client's shape, in four ways.** Each is a real difference in the route table,
79
+ not a stylistic one, and assuming otherwise produces a bug that type-checks:
80
+
81
+ 1. **The two reads are `GET` with a query string**; the three writes are `POST`. A read sent as POST is a `405`.
82
+ 2. **`list` is enveloped** β€” `{ items, metadata: { nextCursor } }`. The cursor sits one level deeper than
83
+ `app-storage/list`'s top-level `nextCursor`; read the wrong level and pagination is silently dead while
84
+ every page still parses.
85
+ 3. **`append` accepts no key** β€” the server mints a ULID, so one viewer cannot overwrite another's row.
86
+ 4. **Anonymous viewers read but never write** β€” the inverse of per-viewer storage, where a missing subject
87
+ refuses everything. See *Who may read, and who may write* below.
88
+
89
+ ### Porting off the bridge: `useSharedStorage` β†’ `app.sharedStorage`
90
+
91
+ `@civitai/blocks-react`'s `useSharedStorage` hook is what the fleet uses today, and it is **the left column of
92
+ the table at the top of this document, not a rival client of the right one.** It sends `SHARED_LIST`,
93
+ `SHARED_GET`, … over `postMessage` and never touches `/api/v1`; the host receives those messages and calls
94
+ tRPC `apps.shared.*` on the block's behalf. Its own doc says the block *"never sees the datastore credentials
95
+ and never sends its block token (the host injects both)"*.
96
+
97
+ `app.sharedStorage` is the other side of that migration: the transport becomes a direct `fetch` of
98
+ `/api/v1/blocks/shared-storage/*`, from the block's opaque origin, instead of a message to the host.
99
+
100
+ **The credential and the CORS declaration are already handled β€” for both, by code that exists.** `initialize()`
101
+ wires the host session into the client, `createHttp` puts `Authorization: Bearer <token>` on every request and
102
+ retries once on a `401` with a freshly fetched token, and the token still comes **from the host** over the
103
+ bridge (`REQUEST_TOKEN`) β€” the host continues to mint and rotate it. Scopes are unchanged between the two
104
+ transports (see above). So there is no token plumbing, no CORS work and no manifest change to do.
105
+
106
+ ⚠ **The CORS mechanism differs by trust tier, though neither tier costs you any work.** An unverified block runs
107
+ without `allow-same-origin`, so it fetches from an **opaque origin** and the five routes' `allowOpaqueOrigin`
108
+ opt-in makes the server answer `ACAO: null`. An `internal`- or `verified`-tier block **does** get
109
+ `allow-same-origin`, so it fetches from its **real origin**, where that arm does not apply β€” it clears CORS via
110
+ the server's origin allowlist instead, which the platform populates itself from the publish-request approval
111
+ rather than anything an author configures.
112
+
113
+ The work that actually remains is four items, and it is all at the call sites:
114
+
115
+ 1. **Narrow `value` from `unknown`** at every read site β€” see below.
116
+ 2. **Move vote / counter / report logic into your app.** Those methods are not in this client at all; the
117
+ platform routes still exist. See the deliberate-omission note above.
118
+ 3. **Handle refusals as HTTP.** An `ApiError` carrying a `status`, where the bridge surfaced a rejected
119
+ message β€” and the anon-write case is a `403` from the scope binding rather than anything the handler said.
120
+ Read `message ?? error`; see *Error body* below.
121
+ πŸ”΄ **But not every failure is an `ApiError`.** A request the browser blocks β€” a CORS rejection, a DNS or
122
+ network failure β€” never reaches the response layer, so it surfaces as a **fetch `TypeError` with no
123
+ `status` and no body**. A `catch` that assumes `ApiError` will read `undefined` for the status and report the
124
+ wrong thing. The bridge had no such failure mode; this transport does. Branch on
125
+ `error instanceof ApiError` first and treat the rest as transport failure.
126
+ 4. **Audit every `limit` you pass** β€” the normalisation rules changed; see below.
127
+
128
+ ⚠ **This paragraph has had three framings; two claims are retracted.** Recorded so the ground is not re-walked,
129
+ and scoped deliberately β€” each retraction covers only the words quoted, nothing adjacent:
130
+ **(a)** *"a second client for these same routes"* β€” **false**: the hook is the bridge/tRPC path and never
131
+ calls `/api/v1`, so the two are different transports, not two clients of one surface.
132
+ **(b)** *"budget for the token/CORS work"* β€” **false**: both the credential and the CORS declaration are already
133
+ handled, so that advice sent the reader's effort at finished infrastructure while demoting the real work.
134
+ πŸ”΄ The *transport* change is NOT retracted β€” this document still asserts it, above. The list above is what is
135
+ measured; nothing beyond it is claimed.
136
+
137
+ | | `useSharedStorage` (bridge) | `app.sharedStorage` (HTTP) |
138
+ |---|---|---|
139
+ | Transport | `postMessage` β†’ host β†’ tRPC `apps.shared.*` | direct `fetch` of `/api/v1/blocks/shared-storage/*` |
140
+ | Credential | host-injected | the SDK sends the host-minted token for you |
141
+ | Methods | 10, including `vote`/`unvote`/`count`/`report` | 5, key/value only |
142
+ | A listed row's `value` | `SharedAppendValue` β€” the typed write shape | `unknown` |
143
+ | `update` resolves | `void` | `{ ok: true }` |
144
+ | `list({ limit })`, out of range / non-integer / non-finite | **normalised**: clamped to 1…100, floored, or defaulted to 50 | **rejected β€” `400`** on all three |
145
+
146
+ πŸ”΄ **`limit` is the one that bites silently, and it is three changes, not one.** The bridge HOST normalised;
147
+ the REST route VALIDATES and refuses. Measured against the platform's own schema
148
+ (`z.coerce.number().int().min(1).max(100)`), each of these used to succeed and now `400`s:
149
+
150
+ - **Out of range** β€” `200` was clamped to 100, `0` was raised to 1. Both now reject.
151
+ - **Non-integer** β€” `2.5` was floored to 2. Now rejects.
152
+ - **Non-finite or not a number** β€” `NaN`, `Infinity`, `"abc"` and `""` all fell back to **50**. All now reject.
153
+ πŸ”΄ **This is the one to grep for**: `Number(searchParams.get('n'))` on a missing param is `NaN`, which used
154
+ to mean "just give me the default" and is now a failed request.
155
+
156
+ ⚠ The `400` body is `{ error: 'Invalid query', details }`, and the SDK reads `error` first β€” so
157
+ `ApiError.message` is just **"Invalid query"**, naming neither the field nor the reason. The pointer is not
158
+ missing, only moved: **`ApiError.body.details`** carries zod's flattened field errors. Look there, not at
159
+ `.message`, when a read 400s.
160
+
161
+ Pass a value in range, or omit it and take the server's default. This client deliberately does **not** re-clamp:
162
+ the bounds are the server's, a second copy here is the thing that drifts, and a guard in this package enforces
163
+ that the module carries no numeric literal at all.
164
+
165
+ πŸ”΄ **The `value: unknown` is deliberate, and it is the wire-honest reading.** A row you list was written by some
166
+ OTHER viewer's copy of the app β€” possibly an older version, possibly a newer one β€” so its shape is a fact about
167
+ stored data, not a promise a client can keep. Typing it as the *write* shape asserts something nothing checked,
168
+ and it is wrong the moment one viewer ships a schema change. Narrow it at the call site.
169
+
170
+ Consequence: **a port is not an import swap** β€” but the cost is call-site work, not infrastructure. Narrow
171
+ `value`, move the vote/counter/report logic into your app, branch on `ApiError.status`, and check every `limit`.
172
+
60
173
  | Method | Path | Scope |
61
174
  |---|---|---|
62
175
  | `GET` | `/list` | `…shared:read` |
package/README.md CHANGED
@@ -71,13 +71,14 @@ host's consent dialog.
71
71
  **not** an error in itself: the host's OAuth mint is flag-gated, so an opted-in
72
72
  block can legitimately receive the block token, and that token serves the
73
73
  `/api/v1/blocks/*` routes it was minted for, `GET /api/v1/models/{id}`, and
74
- `app.storage` β€” which an OAuth token cannot reach at all. What it does not reach
75
- is the rest of `/api/v1`, the orchestrator or the MCP. So the refusal sits at the
76
- surface, not at startup:
74
+ `app.storage` and `app.sharedStorage` β€” which an OAuth token cannot reach at all.
75
+ What it does not reach is the rest of `/api/v1`, the orchestrator or the MCP. So
76
+ the refusal sits at the surface, not at startup:
77
77
 
78
78
  | You call | Holding a block token, signed in |
79
79
  |---|---|
80
80
  | `app.storage.*` | Works. This is the token app storage requires |
81
+ | `app.sharedStorage.*` | Reads work, anonymous included. Writes additionally need a signed-in viewer past the server's minimum-trust gate, so a `403` there is about the VIEWER, not the token |
81
82
  | `app.site` on `blocks/…` | Works. These are the routes the token was minted for |
82
83
  | `app.site.get('me')` and the rest of `/api/v1` | The API's own 401/403, with `auth: "oauth"` named in the message. `status` and `body` are untouched, so a caller can still branch on them |
83
84
  | `app.orchestration.*` | Rejects **before** the request with a `CivitaiError` naming `auth: "oauth"` β€” the orchestrator accepts no block token on any route, so there is nothing to learn from making the call |
@@ -143,8 +144,11 @@ Routes are addressed by path, so a route `/api/v1` gains needs no release here:
143
144
  const images = await app.site.get('images', { query: { limit: 20, username: 'civitai' } });
144
145
  ```
145
146
 
146
- A `401` is retried once with a fresh token. Any other failure is an `ApiError`
147
- carrying `status`, the parsed `body`, and the server's own message.
147
+ A `401` is retried once with a fresh token. Any failure the server answered is an
148
+ `ApiError` carrying `status`, the parsed `body`, and the server's own message β€”
149
+ while a request the browser never delivered (CORS, DNS, offline) surfaces as a
150
+ fetch `TypeError` with no `status`, so test `error instanceof ApiError` before
151
+ reading one.
148
152
 
149
153
  ## App storage
150
154
 
@@ -190,6 +194,81 @@ try {
190
194
  }
191
195
  ```
192
196
 
197
+ ## Shared storage
198
+
199
+ `app.sharedStorage` is this app's **cross-user** key/value store β€” one namespace
200
+ every viewer reads and writes, unlike `app.storage`, which is private per viewer.
201
+ Five methods: `list`, `get`, `append`, `update`, `withdraw`.
202
+
203
+ ```ts
204
+ const { items, nextCursor } = await app.sharedStorage.list({ prefix: 'deck:' });
205
+ const { key } = await app.sharedStorage.append({ title: 'My deck', data: { cards } });
206
+ const row = await app.sharedStorage.get(key); // null when hidden or absent
207
+ await app.sharedStorage.update(key, { title: 'Renamed' });
208
+ const { deleted } = await app.sharedStorage.withdraw(key);
209
+ ```
210
+
211
+ πŸ”΄ **Generic key/value on purpose β€” and deliberately smaller than the route
212
+ table.** The platform serves eleven shared-storage routes; this client wraps the
213
+ five that are key/value operations. `vote`, `unvote`, `counts`, `top`,
214
+ `increment` and `report` are **intentionally absent**: an app that needs voting,
215
+ counters or reporting builds them at its own layer on top of these five, and the
216
+ platform surface expands only if demand shows up. The routes existing is not a
217
+ reason to expect a method here.
218
+
219
+ Reading a tally still works β€” `SharedItem` carries `count` and `viewerVoted` as
220
+ the server projects them, which is exactly what makes an app-layer vote feature
221
+ possible. You just cast the vote yourself.
222
+
223
+ πŸ”΄ **Reads and writes have different audiences, and one does not imply the
224
+ other.** This is the opposite of `app.storage`, so do not carry that gate over:
225
+
226
+ - **An anonymous viewer MAY read** (`list`, `get`) β€” signed-out browsing is a
227
+ supported path β€” and may **never** write. An anon write is a `403` from the
228
+ scope binding, not the `401` a missing token gives.
229
+ - **A signed-in viewer is not automatically a permitted writer.** Writes clear a
230
+ server-side minimum-trust gate (account age, paid tier, verified email or a
231
+ linked OAuth account). Treat a write refusal as a normal outcome with a real
232
+ message in the UI, not an unexpected error.
233
+
234
+ So gate the write affordances on `app.viewer`, and render the reads regardless.
235
+
236
+ Four shape differences from `app.storage`, each of which would otherwise be a bug
237
+ that type-checks:
238
+
239
+ - **The two reads are `GET`**; the three writes are `POST`. A read sent as `POST`
240
+ is a `405`.
241
+ - **`list` returns `items` with values included** β€” so one request renders a page
242
+ rather than needing a `get` per key β€” and its `nextCursor` arrives nested under
243
+ the reply's `metadata`. The client lifts it for you; the rule is the same as
244
+ `app.storage`'s, absence proves the scan completed.
245
+ - **`append` takes no key.** The server mints a ULID and returns it, so one
246
+ viewer cannot overwrite another's row. Use `update` for an in-place edit of a
247
+ row this viewer authored.
248
+ - **`value` is `unknown` on the way out.** The row was written by some other
249
+ viewer's copy of your app, possibly an older version, so its shape is a fact
250
+ about the data rather than a promise the client can make. Narrow it yourself.
251
+
252
+ ⚠ **`limit` is validated, not normalised.** Out of range, non-integer, `NaN` and
253
+ `Infinity` are each a `400` and a rejected promise β€” not a silently adjusted page.
254
+ Pass a value inside the server's range, or omit it and take the server's default.
255
+ πŸ”΄ The sharp edge: `Number(searchParams.get('n'))` on a missing param is `NaN`, so
256
+ a pattern that used to fall through to a default is now a failed request.
257
+
258
+ The `400` body is `{ error: 'Invalid query', details }` and the SDK reads `error`
259
+ first, so `ApiError.message` says only "Invalid query" β€” the field-level reason is
260
+ in `ApiError.body.details`.
261
+
262
+ If you are porting from `@civitai/blocks-react`'s `useSharedStorage`, the bridge
263
+ *host* normalised all of this for you and nothing normalises it here;
264
+ [`BREAKING.md`](./BREAKING.md) covers what the port does and does not cost.
265
+
266
+ `withdraw` answers `{ ok: true, deleted: false }` identically for another
267
+ author's key, an already-withdrawn row and a key that never existed β€” it is not
268
+ an existence oracle, so do not report that as "someone else owns this". Put every
269
+ string other viewers will read in `title`/`body`, which the server moderates;
270
+ `data` is unmoderated app structure.
271
+
193
272
  ## The orchestrator
194
273
 
195
274
  Steps are typed by `$type`: `input` is checked against that step's own input,
@@ -3,6 +3,7 @@ import type { BlockTransport } from '../core/transport.js';
3
3
  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
+ import { type SharedStorageClient } from '../shared-storage/index.js';
6
7
  import { type SiteClient } from '../site/index.js';
7
8
  import { type StorageClient } from '../storage/index.js';
8
9
  export interface AppClient {
@@ -26,6 +27,18 @@ export interface AppClient {
26
27
  * reading an empty result as "nothing stored".
27
28
  */
28
29
  readonly storage: StorageClient;
30
+ /**
31
+ * This app's CROSS-USER store β€” one namespace every viewer of this app reads
32
+ * and writes, which is what makes a shared feed or a "popular" rail possible.
33
+ *
34
+ * πŸ”΄ Requires the block token, like {@link AppClient.storage}, but the audience
35
+ * is NOT the same: an ANONYMOUS viewer MAY read here and may never write. So
36
+ * `viewer === null` gates the write affordances, not the read β€” and an anon
37
+ * write surfaces as 403, refused by the scope binding before the handler runs.
38
+ * A signed-in viewer is not automatically a permitted writer either: writes
39
+ * clear a server-side minimum-trust gate.
40
+ */
41
+ readonly sharedStorage: SharedStorageClient;
29
42
  /**
30
43
  * The orchestrator's workflows, as the viewer.
31
44
  *
package/dist/app/index.js CHANGED
@@ -4,6 +4,7 @@ import { createHost, createHostSession } from '../host/index.js';
4
4
  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
+ import { createSharedStorageClient, } from '../shared-storage/index.js';
7
8
  import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
8
9
  import { createStorageClient } from '../storage/index.js';
9
10
  /** Long enough for a slow host page; short enough that a block opened on its own says so. */
@@ -77,16 +78,19 @@ const OAUTH_OPT_IN = 'declare `auth: "oauth"` in block.manifest.json';
77
78
  const BLOCK_NAMESPACE = /^\/*blocks\//;
78
79
  function createAppClient(session, options, holdsBlockTokenNow = () => false) {
79
80
  const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
80
- // One instance for both site-hosted surfaces: `storage`'s routes live under
81
- // the same base URL, so a `siteUrl` override redirects them together.
81
+ // One instance for all three site-hosted surfaces: the two storage clients'
82
+ // routes live under the same base URL, so a `siteUrl` override redirects them
83
+ // together.
82
84
  // Wrapped once, so `BLOCK_NAMESPACE` is the single place that decides where the
83
- // diagnosis applies. `storage` needs no exemption of its own: every one of its
84
- // routes is `blocks/app-storage/*`, which that namespace already excludes, and
85
- // `test/storage/seam.test.ts` pins its `BASE` there textually.
85
+ // diagnosis applies. Neither storage client needs an exemption of its own:
86
+ // their routes are `blocks/app-storage/*` and `blocks/shared-storage/*`, which
87
+ // that namespace already excludes, and `test/storage/seam.test.ts` plus
88
+ // `test/shared-storage/seam.test.ts` pin both `BASE` values there textually.
86
89
  const siteHttp = explainApiRefusal(http(options.siteUrl ?? DEFAULT_SITE_URL), holdsBlockTokenNow);
87
90
  return {
88
91
  site: createSiteClient(siteHttp),
89
92
  storage: createStorageClient(siteHttp),
93
+ sharedStorage: createSharedStorageClient(siteHttp),
90
94
  orchestration: createOrchestrationClient(refuseBlockToken(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL), holdsBlockTokenNow)),
91
95
  requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
92
96
  getToken: (opts) => session.getToken(opts),
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export type { SignIn, SignInOptions } from './sign-in/index.js';
9
9
  export { SCOPES } from './session/index.js';
10
10
  export type { GrantOptions, Scope, TokenOptions, TokenSource } from './session/index.js';
11
11
  export type { SiteClient } from './site/index.js';
12
+ export type { SharedItem, SharedListQuery, SharedListResult, SharedStorageCallOptions, SharedStorageClient, SharedValue, } from './shared-storage/index.js';
12
13
  export type { StorageCallOptions, StorageClient, StorageKeyEntry, StorageListQuery, StorageListResult, StorageQuota, } from './storage/index.js';
13
14
  export { isTerminal, isTerminalStatus } from './orchestration/index.js';
14
15
  export type { OrchestrationClient, Step, StepTemplate, WaitOptions, Workflow, WorkflowPage, WorkflowQuery, WorkflowStatus, WorkflowTemplate, } from './orchestration/index.js';
@@ -0,0 +1,153 @@
1
+ import type { Http } from '../http/index.js';
2
+ export interface SharedStorageCallOptions {
3
+ signal?: AbortSignal;
4
+ }
5
+ /**
6
+ * The moderated, user-visible text plus the opaque app-owned blob.
7
+ *
8
+ * πŸ”΄ `title` and `body` run the server's BLOCKING content-safety belt on every
9
+ * write. `data` does NOT β€” it is unmoderated app structure. Put every string
10
+ * another viewer will read in `title`/`body`; a text surface smuggled into
11
+ * `data` reaches other users unmoderated, which is the one thing this split
12
+ * exists to prevent.
13
+ */
14
+ export interface SharedValue {
15
+ title: string;
16
+ body?: string;
17
+ data?: unknown;
18
+ }
19
+ /**
20
+ * One row of the shared store, as `list` and `get` both project it.
21
+ *
22
+ * πŸ”΄ `value` is `unknown`, NOT {@link SharedValue}, and that is deliberate: the
23
+ * row was written by some OTHER viewer's copy of this app β€” possibly an older
24
+ * version, possibly a newer one β€” so its shape is a fact about the data, not a
25
+ * promise this client can make. Narrow it at the call site. Declaring it
26
+ * `SharedValue` would assert a shape nothing checked about data we did not write.
27
+ */
28
+ export interface SharedItem {
29
+ key: string;
30
+ /** The row's author. A caller renders "yours" by comparing it to the viewer's id. */
31
+ authorUserId: number;
32
+ value: unknown;
33
+ /**
34
+ * The row's aggregate up-vote tally, as the server projects it.
35
+ *
36
+ * ⚠ Reported, not writable from here: the vote OPERATIONS are deliberately not
37
+ * part of this client (see the module note). Reading the tally is what lets an
38
+ * app build voting at its own layer, which is why the field stays.
39
+ */
40
+ count: number;
41
+ /**
42
+ * The wire carries ISO strings; both stamps are revived here, once, so a
43
+ * caller can sort and diff without knowing the transport.
44
+ */
45
+ createdAt: Date;
46
+ updatedAt: Date;
47
+ /**
48
+ * Whether THIS viewer has voted. πŸ”΄ Always `false` for an anonymous viewer β€”
49
+ * who may read this store β€” so it is not evidence that nobody voted.
50
+ */
51
+ viewerVoted: boolean;
52
+ }
53
+ export interface SharedListQuery {
54
+ /** Narrows to keys starting with this. Escaped server-side against LIKE wildcards. */
55
+ prefix?: string;
56
+ /** The server bounds this and applies its own default; this client sends none. */
57
+ limit?: number;
58
+ /** An opaque `nextCursor` from a previous page. */
59
+ cursor?: string;
60
+ }
61
+ export interface SharedListResult {
62
+ /** Newest-first on the server-generated ULID key. Hidden rows are excluded. */
63
+ items: SharedItem[];
64
+ /**
65
+ * πŸ”΄ PRESENT EXACTLY WHEN THERE MAY BE MORE ROWS, and passed through
66
+ * untouched. The server sets it iff the page it returned was full, and it
67
+ * arrives nested under the reply's `metadata` β€” lifted here, never defaulted,
68
+ * never normalised, never re-derived from `items.length`. A caller uses its
69
+ * ABSENCE as proof a scan completed.
70
+ */
71
+ nextCursor?: string;
72
+ }
73
+ /**
74
+ * This app's CROSS-USER key/value store β€” every viewer of this app reads and
75
+ * writes one namespace, unlike {@link StorageClient}, which is private per viewer.
76
+ *
77
+ * πŸ”΄ DELIBERATELY GENERIC KEY/VALUE, AND DELIBERATELY SMALLER THAN THE ROUTE
78
+ * TABLE. The platform serves eleven shared-storage routes; this client wraps the
79
+ * five that are key/value operations. The higher-level ops β€” `vote`, `unvote`,
80
+ * `counts`, `top`, `increment`, `report` β€” are intentionally absent: an app that
81
+ * needs voting, counters or reporting builds them at its own layer on top of
82
+ * these five, and the platform surface expands only if demand shows up. **The
83
+ * routes existing is not a reason to add a method here.** Do not "fix" this
84
+ * omission.
85
+ *
86
+ * πŸ”΄ Requires the block token the host mints, plus the scopes: every read takes
87
+ * `apps:storage:shared:read` and every write takes `apps:storage:shared:write`.
88
+ * An app that authenticated with an OAuth access token has no shared storage.
89
+ *
90
+ * πŸ”΄ READS AND WRITES HAVE DIFFERENT AUDIENCES, and a caller must not assume one
91
+ * implies the other:
92
+ *
93
+ * - An ANONYMOUS viewer MAY read (`list`, `get`) and may NEVER write. An anon
94
+ * write is refused by the scope binding before the handler runs, so it arrives
95
+ * as **403**, not the 401 a missing token gives.
96
+ * - A signed-in viewer is not automatically a permitted writer: writes clear a
97
+ * minimum-trust gate (account age, paid tier, verified email or a linked OAuth
98
+ * account). Treat a write refusal as a normal outcome and say so in the UI.
99
+ *
100
+ * πŸ”΄ EVERY FAILURE REJECTS. No path here resolves to mean "not written" or
101
+ * "could not read". A caller that must not act on a partial view branches on the
102
+ * rejection, never on an empty result.
103
+ */
104
+ export interface SharedStorageClient {
105
+ /**
106
+ * One page of rows, newest-first. `GET blocks/shared-storage/list`.
107
+ *
108
+ * Values ARE returned, unlike the per-viewer client's `list` β€” so one request
109
+ * renders a page rather than needing a `get` per key.
110
+ */
111
+ list(query?: SharedListQuery, opts?: SharedStorageCallOptions): Promise<SharedListResult>;
112
+ /**
113
+ * One row by key, or `null`. `GET blocks/shared-storage/item`.
114
+ *
115
+ * πŸ”΄ `null` covers MISSING **and** HIDDEN, deliberately and identically β€” a
116
+ * withdrawn or moderator-hidden row is indistinguishable from one that never
117
+ * existed, so this is not an oracle for either. It is a 200, not a 404.
118
+ */
119
+ get(key: string, opts?: SharedStorageCallOptions): Promise<SharedItem | null>;
120
+ /**
121
+ * Files a new row and resolves its key. `POST blocks/shared-storage/append`.
122
+ *
123
+ * πŸ”΄ THE KEY IS THE SERVER'S β€” a ULID it generates, returned here. This method
124
+ * takes no key and there is no create-at-key call, because a client-chosen key
125
+ * would let one viewer overwrite another's row.
126
+ */
127
+ append(value: SharedValue, opts?: SharedStorageCallOptions): Promise<{
128
+ key: string;
129
+ }>;
130
+ /**
131
+ * Edits a row the VIEWER AUTHORED, in place. `POST blocks/shared-storage/update`.
132
+ *
133
+ * Another author's key is a 403 and a missing or hidden one a 404 β€” neither is
134
+ * a silent no-op. Preserved: the key, the author, `createdAt`, and the row's
135
+ * votes, counters and reports. Replaced: the value.
136
+ */
137
+ update(key: string, value: SharedValue, opts?: SharedStorageCallOptions): Promise<{
138
+ ok: true;
139
+ }>;
140
+ /**
141
+ * Deletes a row the VIEWER AUTHORED. `POST blocks/shared-storage/withdraw`.
142
+ *
143
+ * πŸ”΄ `deleted: false` is a SUCCESS, and it is deliberately ambiguous: another
144
+ * author's key, an already-withdrawn row and a key that never existed all
145
+ * answer the same way, so this cannot probe for other viewers' rows. Do not
146
+ * report it to the viewer as "someone else owns this".
147
+ */
148
+ withdraw(key: string, opts?: SharedStorageCallOptions): Promise<{
149
+ ok: true;
150
+ deleted: boolean;
151
+ }>;
152
+ }
153
+ export declare function createSharedStorageClient(http: Http): SharedStorageClient;
@@ -0,0 +1,107 @@
1
+ import { CivitaiError } from '../core/errors.js';
2
+ /** Where the five routes this client wraps live under the site base URL. */
3
+ const BASE = 'blocks/shared-storage';
4
+ export function createSharedStorageClient(http) {
5
+ // πŸ”΄ GET for the reads, POST for the writes β€” the route table's own split, not
6
+ // the per-viewer client's uniform POST. A read sent as POST is a 405.
7
+ const get = (op, query, signal) => http('GET', `${BASE}/${op}`, { query, signal });
8
+ const post = (op, body, signal) => http('POST', `${BASE}/${op}`, { body, signal });
9
+ return {
10
+ async list(query = {}, { signal } = {}) {
11
+ const res = await get('list',
12
+ // Named rather than spread: each of the three is a field a mutation can
13
+ // drop on its own, and an `undefined` is omitted from the query string,
14
+ // so the server's own default applies instead of a second copy here.
15
+ { prefix: query.prefix, limit: query.limit, cursor: query.cursor }, signal);
16
+ // πŸ”΄ NO `?? []`. A malformed 200 must throw, never answer "no rows": a
17
+ // caller reads an empty page as "this store is empty" and renders that to
18
+ // every viewer, and a manufactured empty page is indistinguishable.
19
+ if (!Array.isArray(res?.items)) {
20
+ throw new CivitaiError('shared-storage list: reply carried no `items` array');
21
+ }
22
+ // πŸ”΄ GUARDED SYMMETRICALLY WITH `items`, and for the same reason. `metadata`
23
+ // is where `nextCursor` lives, and this method's contract is that the
24
+ // cursor's ABSENCE proves a scan completed. A `?? {}` here would turn a
25
+ // malformed envelope into `nextCursor: undefined` β€” so a caller's
26
+ // `do…while (cursor)` loop stops after page one and renders 50 of 5,000 rows
27
+ // as the whole store, silently. The identical malformation of `items` throws;
28
+ // treating the container the cursor arrives in as optional would have made
29
+ // the louder half of this reply strict and the quieter half lenient.
30
+ if (res.metadata == null || typeof res.metadata !== 'object') {
31
+ throw new CivitaiError('shared-storage list: reply carried no `metadata` object');
32
+ }
33
+ const metadata = res.metadata;
34
+ return {
35
+ items: res.items.map(toItem),
36
+ // πŸ”΄ Lifted out of `metadata` and passed through. Not defaulted, not
37
+ // normalised, not re-derived.
38
+ nextCursor: metadata.nextCursor,
39
+ };
40
+ },
41
+ async get(key, { signal } = {}) {
42
+ const res = await get('item', { key }, signal);
43
+ // πŸ”΄ NO `res?.item ?? null`. `null` is this method's word for "no visible
44
+ // row", a real answer a caller acts on. A reply that never carried `item`
45
+ // is not that answer, and must not be dressed up as it.
46
+ if (res == null || typeof res !== 'object' || !('item' in res)) {
47
+ throw new CivitaiError('shared-storage get: reply carried no `item`');
48
+ }
49
+ return res.item == null ? null : toItem(res.item);
50
+ },
51
+ async append(value, { signal } = {}) {
52
+ const res = await post('append', { value }, signal);
53
+ // Asserted, not cast: this key is the ONLY handle the caller will ever have
54
+ // on the row it just created, and a cast would make the declared `string` a
55
+ // lie we cannot see.
56
+ if (typeof res?.key !== 'string') {
57
+ throw new CivitaiError('shared-storage append: reply carried no `key`');
58
+ }
59
+ return { key: res.key };
60
+ },
61
+ async update(key, value, { signal } = {}) {
62
+ await post('update', { key, value }, signal);
63
+ // The route answers `{ ok: true }` and every failure has already rejected,
64
+ // so there is no second field to check β€” resolving IS the answer.
65
+ return { ok: true };
66
+ },
67
+ async withdraw(key, { signal } = {}) {
68
+ const res = await post('withdraw', { key }, signal);
69
+ if (typeof res?.deleted !== 'boolean') {
70
+ throw new CivitaiError('shared-storage withdraw: reply carried no `deleted` flag');
71
+ }
72
+ return { ok: true, deleted: res.deleted };
73
+ },
74
+ };
75
+ }
76
+ function toItem(row) {
77
+ const r = (row ?? {});
78
+ if (typeof r.key !== 'string' ||
79
+ typeof r.authorUserId !== 'number' ||
80
+ typeof r.count !== 'number' ||
81
+ typeof r.viewerVoted !== 'boolean' ||
82
+ !('value' in r)) {
83
+ throw new CivitaiError('shared-storage: malformed item');
84
+ }
85
+ return {
86
+ key: r.key,
87
+ authorUserId: r.authorUserId,
88
+ value: r.value,
89
+ count: r.count,
90
+ createdAt: toDate(r.createdAt),
91
+ updatedAt: toDate(r.updatedAt),
92
+ viewerVoted: r.viewerVoted,
93
+ };
94
+ }
95
+ function toDate(raw) {
96
+ // `new Date(null)` is the epoch, not an Invalid Date, so the NaN check below
97
+ // cannot be the only gate β€” a null timestamp would sort and compare silently
98
+ // wrong forever.
99
+ if (!(typeof raw === 'string' || raw instanceof Date)) {
100
+ throw new CivitaiError('shared-storage: malformed item timestamp');
101
+ }
102
+ const at = new Date(raw);
103
+ if (Number.isNaN(at.getTime())) {
104
+ throw new CivitaiError('shared-storage: malformed item timestamp');
105
+ }
106
+ return at;
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The Civitai SDK: an app holds a token and calls the Civitai API and the orchestrator; a block asks its host only to show host UI.",
5
5
  "license": "MIT",
6
6
  "type": "module",