@civitai/sdk 0.6.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,
@@ -374,6 +453,91 @@ not exported, because four of the five fleet apps that store per-viewer state
374
453
  need knobs it does not have (injected latency, prefix-targeted refusals, quota
375
454
  overrides, a cursor it ignores, a read that never settles). Copy it if it helps.
376
455
 
456
+ ## Web storage in a block
457
+
458
+ A block is framed at an **opaque origin**: civitai's `intersectSandbox` adds
459
+ `allow-same-origin` only for the `internal` and `verified` trust tiers, and in
460
+ v1 every approved block is `unverified`. There, `localStorage` and
461
+ `sessionStorage` do not merely come back empty β€” *reading the property throws*:
462
+
463
+ ```
464
+ SecurityError: Failed to read the 'localStorage' property from 'Window':
465
+ The document is sandboxed and lacks the 'allow-same-origin' flag.
466
+ ```
467
+
468
+ Guarding your own call sites is not enough, because a dependency that touches
469
+ storage while its module body evaluates takes the app down before any of your
470
+ code runs β€” and libraries routinely mislabel the failure as something else.
471
+
472
+ So the package ships a repair behind its own subpath. Importing
473
+ `@civitai/sdk/safe-storage` installs a spec-shaped in-memory `Storage` over any
474
+ web-storage global that is present but unusable. It is inert where storage works
475
+ and where there is none at all (Node/SSR/workers), and it never replaces a
476
+ healthy store.
477
+
478
+ πŸ”΄ **This is not automatic. Add the import yourself, as the first import in your
479
+ entry module** β€” `import '@civitai/sdk'` on its own installs nothing:
480
+
481
+ ```
482
+ import '@civitai/sdk/safe-storage'; // side-effect import; keep it FIRST
483
+ import 'some-library-that-reads-localStorage';
484
+ ```
485
+
486
+ The import *is* the install, so for most blocks that line is the whole of it.
487
+ There is nothing to configure. The subpath does export two functions, for the
488
+ cases the import alone cannot cover:
489
+
490
+ - `installSafeStorage(scope?)` β€” run the repair at a *specific moment* rather
491
+ than at module-evaluation time. The one that matters: immediately before an
492
+ `await import('some-lib')` that reads storage while evaluating. A hoisted
493
+ static import cannot be placed "after" anything, so a dynamic import is the
494
+ case the side-effect line does not solve. Returns which globals it replaced.
495
+ - `createMemoryStorage()` β€” the spec-shaped in-memory `Storage` itself, if you
496
+ want one without touching any global.
497
+
498
+ Two things about that line:
499
+
500
+ - **First is the whole point.** ES imports are hoisted and evaluated in order,
501
+ so a storage-touching dependency imported *above* this one still evaluates
502
+ first and nothing can reach it in time. Put it above every other import,
503
+ including your framework's.
504
+ - **It is a side-effect-only import, so bundlers must be told to keep it.** The
505
+ package declares a `sideEffects` allowlist naming
506
+ `./dist/safe-storage/index.js` for exactly that reason; `sideEffects: false`
507
+ would license every bundler to drop it. The package root
508
+ (`./dist/index.js`) is deliberately *not* in that allowlist β€” it is pure
509
+ re-exports and stays fully tree-shakeable.
510
+
511
+ ### Two things "inert where storage works" hides
512
+
513
+ Both only ever happen at a **real** origin β€” neither can occur in the block
514
+ sandbox this subpath exists for.
515
+
516
+ - **It writes a probe key.** Classifying a store means a real round trip, so at
517
+ import each of `localStorage` and `sessionStorage` gets one
518
+ `setItem('__civitai_app_sdk_storage_probe__', '1')` followed immediately by
519
+ `removeItem`. Net contents are unchanged and neither store object is replaced
520
+ β€” but per the Web Storage spec each write queues a `storage` event in the
521
+ other documents sharing the store, so a cross-tab listener that does not
522
+ filter by key will see spurious events.
523
+ - **A FULL store counts as broken.** The write probe cannot tell "quota
524
+ exhausted" from "storage disabled" β€” a `QuotaExceededError` classifies the
525
+ store as unusable, and the repair swaps in the in-memory fallback, seeded
526
+ first from every entry it could still read. Nothing readable is lost, but the
527
+ store stops persisting: writes after that point are session-scoped and gone
528
+ on reload. In a block that is the honest semantic regardless. In an app at a
529
+ real origin with a full `localStorage`, it is a downgrade from persistent to
530
+ session-scoped that you opted into by importing this subpath.
531
+
532
+ A block built on `@civitai/app-sdk/blocks` already gets this: that entry imports
533
+ its own copy (`@civitai/app-sdk/safe-storage`). A block that has finished porting
534
+ to `@civitai/sdk` imports neither, which is why this line exists.
535
+
536
+ The fallback is session-scoped β€” nothing survives a reload, which is the honest
537
+ semantic at an opaque origin, since there is no origin to persist against. Treat
538
+ web storage in a block as a cache, never a source of truth. The durable
539
+ per-viewer store is [app storage](#app-storage).
540
+
377
541
  ## Checks
378
542
 
379
543
  - `npm run api` regenerates [`api/public-api.md`](./api/public-api.md) from the
@@ -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';
package/dist/index.js CHANGED
@@ -2,6 +2,17 @@
2
2
  * `const app = await initialize()`, then `app.site`, `app.orchestration` and,
3
3
  * inside a civitai.com page, `app.host`. Outside one, `createSignIn()` gets the token.
4
4
  */
5
+ // πŸ”΄ NOTHING IS IMPORTED FOR SIDE EFFECTS HERE, on purpose. This entry is pure
6
+ // re-exports, so `./dist/index.js` stays droppable and `import '@civitai/sdk'`
7
+ // stays inert in Node/SSR.
8
+ //
9
+ // The opaque-origin web-storage repair lives behind its own subpath,
10
+ // `@civitai/sdk/safe-storage` (`./safe-storage/index.ts`), and a block opts in
11
+ // with one line at the top of its entry module. Installing it from here would
12
+ // make every consumer of this package β€” server runtimes included β€” pay a side
13
+ // effect it never asked for, and would still not fix the ordering problem: a
14
+ // storage-touching dependency imported ABOVE `@civitai/sdk` evaluates first
15
+ // either way. See the README's "Web storage in a block".
5
16
  export { initialize } from './app/index.js';
6
17
  export { createSignIn, SignInError } from './sign-in/index.js';
7
18
  export { SCOPES } from './session/index.js';
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Survive an opaque-origin sandbox. Published as the side-effect subpath
3
+ * `@civitai/sdk/safe-storage`; a block opts in with `import
4
+ * '@civitai/sdk/safe-storage';` as the first line of its entry module. The
5
+ * package root does NOT import it β€” see `src/index.ts`.
6
+ *
7
+ * πŸ”΄ **This is a deliberate independent COPY of `@civitai/app-sdk`'s
8
+ * `src/safe-storage/index.ts`, not a shared module.** The two packages are
9
+ * separate codebases (`@civitai/sdk` succeeds `@civitai/app-sdk`) and must not
10
+ * depend on each other β€” a successor that imports its predecessor would drag
11
+ * the whole 0.x surface back in. The duplication is the price of that
12
+ * independence. The two bodies are held byte-identical modulo comments by
13
+ * `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix to one goes red
14
+ * until it is applied to the other β€” do not rely on remembering.
15
+ *
16
+ * Why this has to exist here at all: a block that has finished porting to
17
+ * `@civitai/sdk` no longer imports `@civitai/app-sdk/blocks`, which is what
18
+ * used to install the shim. `civitai-app-requests` is exactly that shape today
19
+ * β€” its only remaining `@civitai/app-sdk/blocks` import is in a test file β€” so
20
+ * without this module its shipped bundle has no repair at all.
21
+ *
22
+ * Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
23
+ * deliberately WITHOUT `allow-same-origin` (see civitai's
24
+ * `src/components/AppBlocks/sandbox.ts`: `intersectSandbox` adds
25
+ * `allow-same-origin` only for the `internal`/`verified` trust tiers, and in v1
26
+ * every approved block is `unverified`). The document therefore has an
27
+ * **opaque origin**, and there is no origin to key web storage against, so the
28
+ * platform does not merely return an empty store β€” merely *reading* the
29
+ * property throws:
30
+ *
31
+ * SecurityError: Failed to read the 'localStorage' property from 'Window':
32
+ * The document is sandboxed and lacks the 'allow-same-origin' flag.
33
+ *
34
+ * Guarding your own call sites is not enough: any third-party dependency that
35
+ * touches storage unguarded takes the whole app down, and libraries routinely
36
+ * *mislabel* the failure. A real production example β€” Photo Sphere Viewer's
37
+ * `SYSTEM.load()` runs an unguarded ``TOUCH_KEY in localStorage`` touch probe,
38
+ * its `Viewer` constructor catches the resulting SecurityError, reports the
39
+ * generic "Your browser does not seem to support WebGL", and returns without
40
+ * rethrowing. The app's own fallback never ran and users with perfectly good
41
+ * GPUs saw a WebGL error. (The sandbox does not block WebGL β€” only storage.)
42
+ *
43
+ * So this module installs a spec-shaped in-memory `Storage` over any
44
+ * `localStorage`/`sessionStorage` that is present but unusable. Importing it
45
+ * runs the install (see the bottom of the file).
46
+ *
47
+ * The fallback is session-scoped β€” nothing survives a reload β€” which is the
48
+ * honest semantic at an opaque origin: there is no origin to persist against.
49
+ * Treat storage in a block as a cache, never a source of truth; the durable
50
+ * per-user store is the platform's app-storage API.
51
+ */
52
+ /** Which globals a call to {@link installSafeStorage} actually replaced. */
53
+ export interface SafeStorageInstallResult {
54
+ /** `true` when `localStorage` was replaced by the in-memory fallback. */
55
+ localStorage: boolean;
56
+ /** `true` when `sessionStorage` was replaced by the in-memory fallback. */
57
+ sessionStorage: boolean;
58
+ }
59
+ declare const STORAGE_NAMES: readonly ['localStorage', 'sessionStorage'];
60
+ /** The web-storage globals this module can repair. */
61
+ export type SafeStorageName = (typeof STORAGE_NAMES)[number];
62
+ /**
63
+ * A `Storage` work-alike backed by a `Map`.
64
+ *
65
+ * Implemented as a **Proxy, not a class**, because real `Storage` is an exotic
66
+ * object: `s.foo = 1`, `'foo' in s`, `s.foo` and `delete s.foo` are aliases for
67
+ * `setItem`/`getItem`/`removeItem`. Libraries use exactly that form (PSV's
68
+ * touch probe is literally ``KEY in localStorage`` then
69
+ * `localStorage[KEY] === 'true'`), so a plain class would still break them.
70
+ */
71
+ export declare function createMemoryStorage(): Storage;
72
+ /**
73
+ * Replace any present-but-unusable `localStorage`/`sessionStorage` on `scope`
74
+ * with an in-memory `Storage`, so third-party code that touches it unguarded
75
+ * cannot throw.
76
+ *
77
+ * - **No-op where storage works β€” but not a no-TOUCH.** A healthy `Storage` is
78
+ * never replaced and its contents are unchanged, but it *is* written to:
79
+ * classifying it means a real round trip, so each of `localStorage` and
80
+ * `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
81
+ * immediately followed by `removeItem`. MEASURED against the built artifact
82
+ * with instrumented healthy stores: exactly those four operations, the store
83
+ * objects not replaced, contents identical before and after. Per the Web
84
+ * Storage spec every one of those writes queues a `storage` event in the
85
+ * *other* documents that share the store, so at a REAL origin a cross-tab
86
+ * listener that does not filter by key sees spurious events (the two
87
+ * `localStorage` writes are the ones another tab receives). At the opaque
88
+ * origin this module exists for there is no other same-origin document, so
89
+ * there is nothing to receive them.
90
+ * - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
91
+ * - **Never loses readable data.** When the old store reads but refuses writes
92
+ * (full / disabled), its entries are copied into the fallback first, so the
93
+ * shim cannot shadow a live session.
94
+ * - **Idempotent.** Once the fallback is installed the round-trip probe
95
+ * succeeds, so a second call is a no-op and values already written survive.
96
+ * - **Never throws.** Every step is guarded; a shim that could fail at import
97
+ * would be a worse outage than the bug it fixes.
98
+ *
99
+ * This module installs on import, so most apps never need to call it. Call it
100
+ * explicitly when you want the guarantee at a specific moment β€” e.g. right
101
+ * before `await import('some-lib')` that reads storage while evaluating.
102
+ *
103
+ * @param scope Object carrying the storage globals. Defaults to `globalThis`.
104
+ */
105
+ export declare function installSafeStorage(scope?: object): SafeStorageInstallResult;
106
+ export {};
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Survive an opaque-origin sandbox. Published as the side-effect subpath
3
+ * `@civitai/sdk/safe-storage`; a block opts in with `import
4
+ * '@civitai/sdk/safe-storage';` as the first line of its entry module. The
5
+ * package root does NOT import it β€” see `src/index.ts`.
6
+ *
7
+ * πŸ”΄ **This is a deliberate independent COPY of `@civitai/app-sdk`'s
8
+ * `src/safe-storage/index.ts`, not a shared module.** The two packages are
9
+ * separate codebases (`@civitai/sdk` succeeds `@civitai/app-sdk`) and must not
10
+ * depend on each other β€” a successor that imports its predecessor would drag
11
+ * the whole 0.x surface back in. The duplication is the price of that
12
+ * independence. The two bodies are held byte-identical modulo comments by
13
+ * `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix to one goes red
14
+ * until it is applied to the other β€” do not rely on remembering.
15
+ *
16
+ * Why this has to exist here at all: a block that has finished porting to
17
+ * `@civitai/sdk` no longer imports `@civitai/app-sdk/blocks`, which is what
18
+ * used to install the shim. `civitai-app-requests` is exactly that shape today
19
+ * β€” its only remaining `@civitai/app-sdk/blocks` import is in a test file β€” so
20
+ * without this module its shipped bundle has no repair at all.
21
+ *
22
+ * Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
23
+ * deliberately WITHOUT `allow-same-origin` (see civitai's
24
+ * `src/components/AppBlocks/sandbox.ts`: `intersectSandbox` adds
25
+ * `allow-same-origin` only for the `internal`/`verified` trust tiers, and in v1
26
+ * every approved block is `unverified`). The document therefore has an
27
+ * **opaque origin**, and there is no origin to key web storage against, so the
28
+ * platform does not merely return an empty store β€” merely *reading* the
29
+ * property throws:
30
+ *
31
+ * SecurityError: Failed to read the 'localStorage' property from 'Window':
32
+ * The document is sandboxed and lacks the 'allow-same-origin' flag.
33
+ *
34
+ * Guarding your own call sites is not enough: any third-party dependency that
35
+ * touches storage unguarded takes the whole app down, and libraries routinely
36
+ * *mislabel* the failure. A real production example β€” Photo Sphere Viewer's
37
+ * `SYSTEM.load()` runs an unguarded ``TOUCH_KEY in localStorage`` touch probe,
38
+ * its `Viewer` constructor catches the resulting SecurityError, reports the
39
+ * generic "Your browser does not seem to support WebGL", and returns without
40
+ * rethrowing. The app's own fallback never ran and users with perfectly good
41
+ * GPUs saw a WebGL error. (The sandbox does not block WebGL β€” only storage.)
42
+ *
43
+ * So this module installs a spec-shaped in-memory `Storage` over any
44
+ * `localStorage`/`sessionStorage` that is present but unusable. Importing it
45
+ * runs the install (see the bottom of the file).
46
+ *
47
+ * The fallback is session-scoped β€” nothing survives a reload β€” which is the
48
+ * honest semantic at an opaque origin: there is no origin to persist against.
49
+ * Treat storage in a block as a cache, never a source of truth; the durable
50
+ * per-user store is the platform's app-storage API.
51
+ */
52
+ const STORAGE_NAMES = ['localStorage', 'sessionStorage'];
53
+ const PROBE_KEY = '__civitai_app_sdk_storage_probe__';
54
+ /**
55
+ * A `Storage` work-alike backed by a `Map`.
56
+ *
57
+ * Implemented as a **Proxy, not a class**, because real `Storage` is an exotic
58
+ * object: `s.foo = 1`, `'foo' in s`, `s.foo` and `delete s.foo` are aliases for
59
+ * `setItem`/`getItem`/`removeItem`. Libraries use exactly that form (PSV's
60
+ * touch probe is literally ``KEY in localStorage`` then
61
+ * `localStorage[KEY] === 'true'`), so a plain class would still break them.
62
+ */
63
+ export function createMemoryStorage() {
64
+ const map = new Map();
65
+ const api = {
66
+ getItem(key) {
67
+ const k = String(key);
68
+ return map.has(k) ? map.get(k) : null;
69
+ },
70
+ setItem(key, value) {
71
+ map.set(String(key), String(value));
72
+ },
73
+ removeItem(key) {
74
+ map.delete(String(key));
75
+ },
76
+ clear() {
77
+ map.clear();
78
+ },
79
+ key(index) {
80
+ return Array.from(map.keys())[index] ?? null;
81
+ },
82
+ get length() {
83
+ return map.size;
84
+ },
85
+ };
86
+ // `Reflect.has` walks the prototype chain, so `'toString' in storage` stays
87
+ // true exactly as it is for a real Storage instance. Only keys that are NOT
88
+ // part of the Storage API fall through to the backing map.
89
+ const isApiKey = (prop) => typeof prop === 'symbol' || Reflect.has(api, prop);
90
+ return new Proxy(api, {
91
+ get(target, prop, receiver) {
92
+ if (isApiKey(prop))
93
+ return Reflect.get(target, prop, receiver);
94
+ return map.get(prop);
95
+ },
96
+ set(target, prop, value, receiver) {
97
+ if (isApiKey(prop))
98
+ return Reflect.set(target, prop, value, receiver);
99
+ map.set(prop, String(value));
100
+ return true;
101
+ },
102
+ has(_target, prop) {
103
+ return isApiKey(prop) || map.has(prop);
104
+ },
105
+ deleteProperty(_target, prop) {
106
+ // `delete storage.getItem` must NOT destroy the method, and
107
+ // `delete storage.length` must NOT leave `length` undefined β€” a
108
+ // `for (i < storage.length)` loop would then silently iterate zero
109
+ // times. Real `Storage` is unbothered: its API lives on
110
+ // `Storage.prototype`, so deleting it from the instance finds no own
111
+ // property, reports success and changes nothing. Mirror that exactly.
112
+ if (isApiKey(prop))
113
+ return true;
114
+ map.delete(prop);
115
+ return true;
116
+ },
117
+ // Enumeration sees only stored keys β€” matching real Storage, where
118
+ // `Object.keys(localStorage)` lists the entries, not the methods.
119
+ ownKeys() {
120
+ return Array.from(map.keys());
121
+ },
122
+ // A non-extensible target would make the `ownKeys` trap above violate a
123
+ // proxy invariant (it must then report every own key of the target), so a
124
+ // single `Object.freeze(localStorage)` β€” from a SES/`harden()` lockdown
125
+ // sweep, say β€” would make `Object.keys` / spread / `JSON.stringify` /
126
+ // `for...in` throw on this object FOREVER. Refusing keeps enumeration
127
+ // alive and matches the platform: `Storage` is a legacy platform object
128
+ // with a named-property handler, whose `[[PreventExtensions]]` also
129
+ // returns false.
130
+ preventExtensions: () => false,
131
+ getOwnPropertyDescriptor(target, prop) {
132
+ if (!isApiKey(prop) && map.has(prop)) {
133
+ return {
134
+ value: map.get(prop),
135
+ writable: true,
136
+ enumerable: true,
137
+ configurable: true,
138
+ };
139
+ }
140
+ return Reflect.getOwnPropertyDescriptor(target, prop);
141
+ },
142
+ });
143
+ }
144
+ /**
145
+ * Is `scope[name]` Node's *disabled* web-storage stub?
146
+ *
147
+ * Node >= 22 defines `globalThis.localStorage` / `sessionStorage` as lazy
148
+ * accessors. Without `--localstorage-file` they return `undefined` **and emit
149
+ * `ExperimentalWarning: localStorage is not available because
150
+ * --localstorage-file was not provided.` on every read** β€” so importing this
151
+ * module would print that on every server boot and in CI. `in` does not trip
152
+ * the warning, but it does report `true` there, so existence alone is not
153
+ * enough to skip the read.
154
+ *
155
+ * Both signals must agree, and anything ambiguous falls through to a real
156
+ * probe:
157
+ *
158
+ * 1. we are running on Node;
159
+ * 2. the property is an **own, non-enumerable accessor with a setter** β€”
160
+ * Node's replaceable global. The web platform can never match this:
161
+ * `Window.localStorage` is a WebIDL *readonly* attribute, so it is
162
+ * `enumerable: true` with `set: undefined`, in a sandbox exactly as much as
163
+ * anywhere else. That is a spec guarantee, not a guess, and it is why the
164
+ * check does not also test for `window` β€” happy-dom-style test
165
+ * environments expose a `window` while `globalThis.localStorage` is still
166
+ * Node's stub underneath.
167
+ *
168
+ * Skipping costs nothing either way. Without `--localstorage-file` the read
169
+ * would have returned `undefined` β†’ `absent`, which is what we return; *with*
170
+ * it the storage works β†’ `usable`, which is also a no-op.
171
+ */
172
+ function isNodeWebStorageStub(scope, name) {
173
+ const runtime = globalThis;
174
+ if (typeof runtime.process?.versions?.node !== 'string')
175
+ return false;
176
+ const descriptor = Object.getOwnPropertyDescriptor(scope, name);
177
+ return (descriptor !== undefined &&
178
+ typeof descriptor.get === 'function' &&
179
+ typeof descriptor.set === 'function' &&
180
+ descriptor.enumerable === false);
181
+ }
182
+ /**
183
+ * Classify `scope[name]`.
184
+ *
185
+ * Reading the property can succeed while writing still throws (storage
186
+ * disabled, private-browsing quirks, quota exhausted), so probe a real
187
+ * round-trip rather than trusting the getter.
188
+ *
189
+ * `absent` is deliberately distinct from `broken`: a runtime with no web
190
+ * storage at all (Node/SSR, a worker) must be left alone. Inventing a
191
+ * `localStorage` there would flip isomorphic libraries onto their browser code
192
+ * path and cause a *different* class of bug than the one we are fixing.
193
+ *
194
+ * **Never throws.** `installSafeStorage()` runs at module scope, so an escaping
195
+ * error would reject `import '@civitai/sdk'` outright and take down every app
196
+ * and block that imports it β€” a worse failure than the one being fixed.
197
+ */
198
+ function probe(scope, name) {
199
+ // `in` is the only safe existence check, and it has to come first.
200
+ //
201
+ // It runs [[HasProperty]], which does NOT invoke the getter β€” on an ordinary
202
+ // object (`globalThis` included) it runs no user code at all, so it cannot
203
+ // throw and cannot trip Node's stub (above).
204
+ //
205
+ // The conventional `typeof localStorage === 'undefined'` guard is NOT a
206
+ // substitute: inside a real opaque-origin sandbox `typeof localStorage`
207
+ // ALSO throws, because `typeof` on a member/global reference still resolves
208
+ // the property and runs the throwing getter β€” only `typeof` of an
209
+ // *undeclared identifier* is safe, and this identifier is declared.
210
+ // `'localStorage' in window` is the probe that survives there, and it
211
+ // correctly answers `true`: in the sandbox the global exists, it is merely
212
+ // unreadable. So the sandbox is classified `broken` below, never `absent`.
213
+ if (!(name in scope))
214
+ return { health: 'absent' };
215
+ if (isNodeWebStorageStub(scope, name))
216
+ return { health: 'absent' };
217
+ let storage;
218
+ try {
219
+ storage = scope[name];
220
+ }
221
+ catch {
222
+ // The opaque-origin case: the getter itself throws.
223
+ return { health: 'broken' };
224
+ }
225
+ if (storage === undefined || storage === null)
226
+ return { health: 'absent' };
227
+ // Everything below TOUCHES the object we were handed, and every one of those
228
+ // touches can throw β€” a revoked `Proxy` answers even a plain property read
229
+ // with a TypeError, and any exotic object can have a throwing getter. So the
230
+ // shape check lives inside the `try` alongside the round-trip probe rather
231
+ // than in front of it.
232
+ //
233
+ // A property read that throws is classified `broken`, not `usable`: every
234
+ // consumer's `localStorage.getItem(...)` would throw exactly the same way, so
235
+ // the global is unusable by definition β€” the same conclusion the throwing
236
+ // *getter* above already reaches. Leaving it in place would keep a provably
237
+ // lethal global on the page; replacing it cannot produce a false positive,
238
+ // because a healthy `Storage` never throws on method access.
239
+ try {
240
+ if (typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') {
241
+ return { health: 'broken', storage };
242
+ }
243
+ storage.setItem(PROBE_KEY, '1');
244
+ storage.removeItem(PROBE_KEY);
245
+ return { health: 'usable', storage };
246
+ }
247
+ catch {
248
+ return { health: 'broken', storage };
249
+ }
250
+ }
251
+ /**
252
+ * Copy whatever `existing` still holds into `into`, best-effort.
253
+ *
254
+ * The write probe fails for two very different reasons. At an opaque origin
255
+ * there is nothing to read and nothing to preserve. But a *full* (or
256
+ * write-disabled) storage on an ordinary origin β€” historically the
257
+ * Safari-private-mode shape, and the likeliest real-world trigger β€” reads
258
+ * perfectly well: swapping a bare in-memory `Storage` over it would shadow
259
+ * live data, turning "can't save" into "the session is gone". So the fallback
260
+ * inherits the current entries first.
261
+ *
262
+ * Values written *after* the swap are session-scoped and lost on reload, the
263
+ * same as every other install path β€” the fallback never claims to persist.
264
+ *
265
+ * Reading the old store is itself unguarded territory (revoked proxies,
266
+ * throwing getters, an object that is not `Storage`-shaped at all), so the
267
+ * whole copy is best-effort: an empty fallback still beats a global that
268
+ * throws.
269
+ */
270
+ function seedFrom(existing, into) {
271
+ try {
272
+ const size = existing.length;
273
+ if (typeof size !== 'number' || typeof existing.key !== 'function')
274
+ return;
275
+ if (typeof existing.getItem !== 'function')
276
+ return;
277
+ for (let index = 0; index < size; index += 1) {
278
+ const key = existing.key(index);
279
+ if (key === null || key === undefined || key === PROBE_KEY)
280
+ continue;
281
+ const value = existing.getItem(key);
282
+ if (value !== null && value !== undefined)
283
+ into.setItem(key, value);
284
+ }
285
+ }
286
+ catch {
287
+ // Unreadable after all. Nothing to inherit; carry on with an empty store.
288
+ }
289
+ }
290
+ /**
291
+ * Replace any present-but-unusable `localStorage`/`sessionStorage` on `scope`
292
+ * with an in-memory `Storage`, so third-party code that touches it unguarded
293
+ * cannot throw.
294
+ *
295
+ * - **No-op where storage works β€” but not a no-TOUCH.** A healthy `Storage` is
296
+ * never replaced and its contents are unchanged, but it *is* written to:
297
+ * classifying it means a real round trip, so each of `localStorage` and
298
+ * `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
299
+ * immediately followed by `removeItem`. MEASURED against the built artifact
300
+ * with instrumented healthy stores: exactly those four operations, the store
301
+ * objects not replaced, contents identical before and after. Per the Web
302
+ * Storage spec every one of those writes queues a `storage` event in the
303
+ * *other* documents that share the store, so at a REAL origin a cross-tab
304
+ * listener that does not filter by key sees spurious events (the two
305
+ * `localStorage` writes are the ones another tab receives). At the opaque
306
+ * origin this module exists for there is no other same-origin document, so
307
+ * there is nothing to receive them.
308
+ * - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
309
+ * - **Never loses readable data.** When the old store reads but refuses writes
310
+ * (full / disabled), its entries are copied into the fallback first, so the
311
+ * shim cannot shadow a live session.
312
+ * - **Idempotent.** Once the fallback is installed the round-trip probe
313
+ * succeeds, so a second call is a no-op and values already written survive.
314
+ * - **Never throws.** Every step is guarded; a shim that could fail at import
315
+ * would be a worse outage than the bug it fixes.
316
+ *
317
+ * This module installs on import, so most apps never need to call it. Call it
318
+ * explicitly when you want the guarantee at a specific moment β€” e.g. right
319
+ * before `await import('some-lib')` that reads storage while evaluating.
320
+ *
321
+ * @param scope Object carrying the storage globals. Defaults to `globalThis`.
322
+ */
323
+ export function installSafeStorage(scope = globalThis) {
324
+ const result = { localStorage: false, sessionStorage: false };
325
+ for (const name of STORAGE_NAMES) {
326
+ const { health, storage } = probe(scope, name);
327
+ if (health !== 'broken')
328
+ continue;
329
+ // Inherit anything the old store still holds, so a storage that reads
330
+ // fine but refuses writes (full / disabled) does not lose its data behind
331
+ // the shim. Skipped at an opaque origin, where the read itself threw and
332
+ // there is nothing to inherit.
333
+ const fallback = createMemoryStorage();
334
+ if (storage)
335
+ seedFrom(storage, fallback);
336
+ try {
337
+ Object.defineProperty(scope, name, {
338
+ value: fallback,
339
+ // `configurable: true` on purpose β€” a consumer (or a second call) can
340
+ // still swap in its own store via `Object.defineProperty`.
341
+ configurable: true,
342
+ // `writable: false` matches the browser: `window.localStorage = x` is
343
+ // a no-op in sloppy mode and a TypeError in strict mode on the real
344
+ // thing, sandboxed or not. A writable shim would quietly accept that
345
+ // assignment and hand blocks a *different* behaviour from production.
346
+ writable: false,
347
+ enumerable: false,
348
+ });
349
+ result[name] = true;
350
+ }
351
+ catch {
352
+ // Non-configurable in this engine β€” nothing more we can do. Swallowing
353
+ // keeps a best-effort shim from becoming its own boot failure.
354
+ }
355
+ }
356
+ return result;
357
+ }
358
+ // Install on import.
359
+ //
360
+ // Import order is the whole game: ES module imports are hoisted, so a
361
+ // *statement* can never run before a sibling `import` of a dependency that
362
+ // reads storage while evaluating. Only an import side effect can, which is why
363
+ // this module installs on import rather than exposing a call to make β€” a block
364
+ // writes `import '@civitai/sdk/safe-storage';` as the FIRST import in its
365
+ // entry module and is then ahead of every dependency it imports afterwards.
366
+ //
367
+ // πŸ”΄ That ordering is the app's job and nothing here can enforce it: a
368
+ // storage-touching dependency imported ABOVE this specifier still evaluates
369
+ // first. Keep it first.
370
+ //
371
+ // The blast radius is bounded by the rules above: it only ever replaces a
372
+ // global that is already *provably* unusable, so in every healthy runtime this
373
+ // is a couple of `setItem`/`removeItem` calls and nothing else.
374
+ installSafeStorage();
@@ -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,17 +1,24 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.6.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",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
- "sideEffects": false,
9
+ "comment-sideEffects": "An ALLOWLIST, not `false`. `./dist/safe-storage/index.js` installs the opaque-origin storage repair when imported, and a bare `false` licenses every bundler to drop it. `./dist/index.js` is deliberately NOT listed: the package root is pure re-exports and must stay droppable. Pinned by test/safe-storage.test.ts.",
10
+ "sideEffects": [
11
+ "./dist/safe-storage/index.js"
12
+ ],
10
13
  "exports": {
11
14
  ".": {
12
15
  "types": "./dist/index.d.ts",
13
16
  "import": "./dist/index.js"
14
17
  },
18
+ "./safe-storage": {
19
+ "types": "./dist/safe-storage/index.d.ts",
20
+ "import": "./dist/safe-storage/index.js"
21
+ },
15
22
  "./testing": {
16
23
  "types": "./dist/testing.d.ts",
17
24
  "import": "./dist/testing.js"