@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 +114 -1
- package/README.md +169 -5
- package/dist/app/index.d.ts +13 -0
- package/dist/app/index.js +9 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/safe-storage/index.d.ts +106 -0
- package/dist/safe-storage/index.js +374 -0
- package/dist/shared-storage/index.d.ts +153 -0
- package/dist/shared-storage/index.js +107 -0
- package/package.json +9 -2
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;
|
|
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.
|
|
75
|
-
is the rest of `/api/v1`, the orchestrator or the MCP. So
|
|
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
|
|
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
|
package/dist/app/index.d.ts
CHANGED
|
@@ -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
|
|
81
|
-
// the same base URL, so a `siteUrl` override redirects them
|
|
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.
|
|
84
|
-
// routes
|
|
85
|
-
// `test/storage/seam.test.ts`
|
|
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.
|
|
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"
|