@civitai/sdk 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -374,6 +374,91 @@ not exported, because four of the five fleet apps that store per-viewer state
374
374
  need knobs it does not have (injected latency, prefix-targeted refusals, quota
375
375
  overrides, a cursor it ignores, a read that never settles). Copy it if it helps.
376
376
 
377
+ ## Web storage in a block
378
+
379
+ A block is framed at an **opaque origin**: civitai's `intersectSandbox` adds
380
+ `allow-same-origin` only for the `internal` and `verified` trust tiers, and in
381
+ v1 every approved block is `unverified`. There, `localStorage` and
382
+ `sessionStorage` do not merely come back empty — *reading the property throws*:
383
+
384
+ ```
385
+ SecurityError: Failed to read the 'localStorage' property from 'Window':
386
+ The document is sandboxed and lacks the 'allow-same-origin' flag.
387
+ ```
388
+
389
+ Guarding your own call sites is not enough, because a dependency that touches
390
+ storage while its module body evaluates takes the app down before any of your
391
+ code runs — and libraries routinely mislabel the failure as something else.
392
+
393
+ So the package ships a repair behind its own subpath. Importing
394
+ `@civitai/sdk/safe-storage` installs a spec-shaped in-memory `Storage` over any
395
+ web-storage global that is present but unusable. It is inert where storage works
396
+ and where there is none at all (Node/SSR/workers), and it never replaces a
397
+ healthy store.
398
+
399
+ 🔴 **This is not automatic. Add the import yourself, as the first import in your
400
+ entry module** — `import '@civitai/sdk'` on its own installs nothing:
401
+
402
+ ```
403
+ import '@civitai/sdk/safe-storage'; // side-effect import; keep it FIRST
404
+ import 'some-library-that-reads-localStorage';
405
+ ```
406
+
407
+ The import *is* the install, so for most blocks that line is the whole of it.
408
+ There is nothing to configure. The subpath does export two functions, for the
409
+ cases the import alone cannot cover:
410
+
411
+ - `installSafeStorage(scope?)` — run the repair at a *specific moment* rather
412
+ than at module-evaluation time. The one that matters: immediately before an
413
+ `await import('some-lib')` that reads storage while evaluating. A hoisted
414
+ static import cannot be placed "after" anything, so a dynamic import is the
415
+ case the side-effect line does not solve. Returns which globals it replaced.
416
+ - `createMemoryStorage()` — the spec-shaped in-memory `Storage` itself, if you
417
+ want one without touching any global.
418
+
419
+ Two things about that line:
420
+
421
+ - **First is the whole point.** ES imports are hoisted and evaluated in order,
422
+ so a storage-touching dependency imported *above* this one still evaluates
423
+ first and nothing can reach it in time. Put it above every other import,
424
+ including your framework's.
425
+ - **It is a side-effect-only import, so bundlers must be told to keep it.** The
426
+ package declares a `sideEffects` allowlist naming
427
+ `./dist/safe-storage/index.js` for exactly that reason; `sideEffects: false`
428
+ would license every bundler to drop it. The package root
429
+ (`./dist/index.js`) is deliberately *not* in that allowlist — it is pure
430
+ re-exports and stays fully tree-shakeable.
431
+
432
+ ### Two things "inert where storage works" hides
433
+
434
+ Both only ever happen at a **real** origin — neither can occur in the block
435
+ sandbox this subpath exists for.
436
+
437
+ - **It writes a probe key.** Classifying a store means a real round trip, so at
438
+ import each of `localStorage` and `sessionStorage` gets one
439
+ `setItem('__civitai_app_sdk_storage_probe__', '1')` followed immediately by
440
+ `removeItem`. Net contents are unchanged and neither store object is replaced
441
+ — but per the Web Storage spec each write queues a `storage` event in the
442
+ other documents sharing the store, so a cross-tab listener that does not
443
+ filter by key will see spurious events.
444
+ - **A FULL store counts as broken.** The write probe cannot tell "quota
445
+ exhausted" from "storage disabled" — a `QuotaExceededError` classifies the
446
+ store as unusable, and the repair swaps in the in-memory fallback, seeded
447
+ first from every entry it could still read. Nothing readable is lost, but the
448
+ store stops persisting: writes after that point are session-scoped and gone
449
+ on reload. In a block that is the honest semantic regardless. In an app at a
450
+ real origin with a full `localStorage`, it is a downgrade from persistent to
451
+ session-scoped that you opted into by importing this subpath.
452
+
453
+ A block built on `@civitai/app-sdk/blocks` already gets this: that entry imports
454
+ its own copy (`@civitai/app-sdk/safe-storage`). A block that has finished porting
455
+ to `@civitai/sdk` imports neither, which is why this line exists.
456
+
457
+ The fallback is session-scoped — nothing survives a reload, which is the honest
458
+ semantic at an opaque origin, since there is no origin to persist against. Treat
459
+ web storage in a block as a cache, never a source of truth. The durable
460
+ per-viewer store is [app storage](#app-storage).
461
+
377
462
  ## Checks
378
463
 
379
464
  - `npm run api` regenerates [`api/public-api.md`](./api/public-api.md) from the
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();
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.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"