@civitai/sdk 0.2.0 → 0.3.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 +171 -11
- package/LICENSE +21 -0
- package/README.md +167 -10
- package/dist/app/index.d.ts +10 -0
- package/dist/app/index.js +6 -1
- package/dist/core/transports/iframe-transport.js +2 -0
- package/dist/host/index.d.ts +62 -2
- package/dist/host/index.js +196 -0
- package/dist/host/protocol.d.ts +101 -0
- package/dist/index.d.ts +2 -1
- package/dist/storage/index.d.ts +91 -0
- package/dist/storage/index.js +68 -0
- package/package.json +15 -15
package/BREAKING.md
CHANGED
|
@@ -15,14 +15,120 @@ messages have no destination yet.
|
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| `GET_VIEWER` | `app.site.get('me')` | Route exists |
|
|
17
17
|
| `SUBMIT_WORKFLOW`, `ESTIMATE_WORKFLOW`, `POLL_WORKFLOW`, `CANCEL_WORKFLOW`, `QUERY_APP_WORKFLOWS`, `CANCEL_APP_WORKFLOW` | `app.orchestration` | Works with an OAuth token |
|
|
18
|
-
| `GET_IMAGES_BY_IDS` | `GET /api/v1/images` |
|
|
18
|
+
| `GET_IMAGES_BY_IDS` | `GET /api/v1/images?ids=1,2,3` | Batch, up to **100** ids per request. Misses are reported by OMISSION — see below |
|
|
19
19
|
| `APP_STORAGE_*` | — | No v1 route |
|
|
20
|
-
| `SHARED_*` |
|
|
21
|
-
| `GET_BUZZ_BALANCE
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
20
|
+
| `SHARED_*` | `GET\|POST /api/v1/blocks/shared-storage/*` | **Route exists** — nine of them; see *Shared storage* below |
|
|
21
|
+
| `GET_BUZZ_BALANCE` | `GET /api/v1/blocks/buzz` | **Route exists.** Returns `{ blue, green, yellow }` — a bare object, three numbers |
|
|
22
|
+
| `GET_BUZZ_ACCOUNTS`, `GET_BUZZ_TRANSACTIONS` | — | No v1 route |
|
|
23
|
+
| `CREATE_POST_FROM_APP` | — | No v1 route, **and deliberately staying on the bridge** — see below |
|
|
24
|
+
| `PUBLISH_GENERATION_OUTPUTS` | `app.host.publishGenerationOutputs` | Same reasoning: it raises host UI, so it stays on the bridge — **carried** rather than replaced |
|
|
25
|
+
| `SET_COLLECTION_FOLLOW` | `POST /api/v1/blocks/collections/{id}/follow` | **Route exists** (scope `collections:write:self`). This row previously said "No v1 route"; that was wrong |
|
|
24
26
|
| `GET_DAILY_COMPENSATION`, `GET_WILDCARD_PACK`, `TRACK_EVENT` | — | Not carried |
|
|
25
27
|
|
|
28
|
+
⚠ `TRACK_EVENT` is filed above beside two one-consumer capabilities, which understates it: it is emitted by
|
|
29
|
+
`useBlockAnalytics`, used by **all 7 fleet apps across 40 files**. It is listed as *not carried* because **it has
|
|
30
|
+
no host handler today either** — `hostHandlerParity.ts` marks both hosts N/A, *"analytics fire-and-forget; no
|
|
31
|
+
host-side sink wired (dropped, never hangs)"*. So those 40 call sites are already no-ops; this is net-new
|
|
32
|
+
capability rather than a migration gap.
|
|
33
|
+
|
|
34
|
+
## 🔴 Before you call ANY block REST route: the scope-binding trap
|
|
35
|
+
|
|
36
|
+
The request-time scope-binding check runs over **every scope on your block token**, not just the one the route
|
|
37
|
+
requires. So a scope you declared for an unrelated feature can reject a call that has nothing to do with it,
|
|
38
|
+
with an error naming a scope you never invoked.
|
|
39
|
+
|
|
40
|
+
The common case: an app declaring **`models:read:self`** calls `GET /api/v1/blocks/buzz`. That scope's binding
|
|
41
|
+
wants `query.id` (or `query.modelId`) to match the model in your block context; a buzz request carries neither,
|
|
42
|
+
so it **403s** with `models:read:self bound to different modelId`.
|
|
43
|
+
|
|
44
|
+
**Workaround — pass a param the handler ignores:**
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
await app.site.get('blocks/buzz', { query: { id: context.modelId } });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Applies to all ten current block REST routes (`blocks/buzz` and the nine under `blocks/shared-storage/`).
|
|
51
|
+
Tracked as civitai/civitai#5063. The anon-read case below is the same bug with no workaround.
|
|
52
|
+
|
|
53
|
+
## Shared storage
|
|
54
|
+
|
|
55
|
+
Nine routes under `/api/v1/blocks/shared-storage/`. Reads take `apps:storage:shared:read`, writes take
|
|
56
|
+
`apps:storage:shared:write` — both scopes already existed; neither is new.
|
|
57
|
+
|
|
58
|
+
| Method | Path | Scope |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `GET` | `/list` | `…shared:read` |
|
|
61
|
+
| `GET` | `/item` | `…shared:read` |
|
|
62
|
+
| `GET` | `/counts` | `…shared:read` |
|
|
63
|
+
| `GET` | `/top` | `…shared:read` |
|
|
64
|
+
| `POST` | `/append` · `/update` · `/vote` · `/unvote` · `/withdraw` · `/report` · `/increment` | `…shared:write` |
|
|
65
|
+
|
|
66
|
+
### Who may read, and who may write
|
|
67
|
+
|
|
68
|
+
These rules are enforced server-side and are **not** new policy — the REST routes call the same functions the
|
|
69
|
+
bridge already called, so behaviour is identical on both transports.
|
|
70
|
+
|
|
71
|
+
- **Anonymous viewers MAY read** — by design. `list`, `item`, `counts` and `top` skip the subject check
|
|
72
|
+
entirely. An anon read still requires: a valid block token, the `apps:storage:shared:read` scope, an
|
|
73
|
+
approved app, a non-revoked instance, and the feature flag enabled.
|
|
74
|
+
|
|
75
|
+
🔴 **BUT NOT TODAY, if your app also declares `apps:storage:shared:write`.** An anon token still carries
|
|
76
|
+
that scope (it is consent-exempt, so the anon mint does not strip it), and the request-time scope-binding
|
|
77
|
+
check runs over **every** scope on the token rather than the one the route needs — so it reaches the write
|
|
78
|
+
scope's "requires authenticated subject" rule and returns **403 for an anonymous read**.
|
|
79
|
+
|
|
80
|
+
There is no call-site workaround: an app cannot un-declare the write scope it needs in order to make its
|
|
81
|
+
read path work. Tracked as civitai/civitai#5063.
|
|
82
|
+
|
|
83
|
+
⚠ **This is a behaviour change from the bridge, so check it before porting.** `SHARED_*` over `postMessage`
|
|
84
|
+
gates anonymity **per operation** — an anon read passes there today. On REST it currently does not. If your
|
|
85
|
+
app's premise is signed-out browsing, that path breaks on migration until #5063 lands.
|
|
86
|
+
- **Anonymous viewers may NEVER write or vote.** Every write resolves to `401` for an anon subject.
|
|
87
|
+
- **Authenticated writers must clear a minimum-trust gate.** After the anon check, writes call
|
|
88
|
+
`assertSharedWriteTrust` — account age, paid tier, and verified email *or* a linked OAuth account. A signed-in
|
|
89
|
+
viewer is not automatically a permitted writer.
|
|
90
|
+
- **Tenancy is structural, not checked.** Every query interpolates a schema name derived from the **verified
|
|
91
|
+
token**, never from client input. An app cannot address another app's shared storage because it cannot name it.
|
|
92
|
+
- **Rate limits are per-operation and per-namespace**, so one operation cannot starve another — browsing a feed
|
|
93
|
+
cannot exhaust a viewer's ability to delete their own rows.
|
|
94
|
+
|
|
95
|
+
### Error body
|
|
96
|
+
|
|
97
|
+
The nine routes return `{ message }` on a 4xx. ⚠ The two oldest siblings (`top`, `increment`) return `{ error }`.
|
|
98
|
+
That divergence is deliberate: the newer routes go through the shared error chokepoint rather than forwarding a
|
|
99
|
+
raw database error string, which on this surface can name the app's schema and the offending row value. Write
|
|
100
|
+
clients against `{ message }`; treat `{ error }` as legacy.
|
|
101
|
+
|
|
102
|
+
## Batch image fetch
|
|
103
|
+
|
|
104
|
+
`GET /api/v1/images?ids=1,2,3` — up to **100** ids, matching the ceiling the `GET_IMAGES_BY_IDS` bridge message
|
|
105
|
+
already enforced.
|
|
106
|
+
|
|
107
|
+
🔴 **Misses are reported by omission.** An id you may not see and an id that does not exist are both simply
|
|
108
|
+
absent from `items`. That is deliberate: distinguishing them would leak whether a hidden image exists, which is
|
|
109
|
+
the same non-disclosure `BlockGatedImage` already makes when it collapses its gated verdict. Do not infer
|
|
110
|
+
deletion from absence.
|
|
111
|
+
|
|
112
|
+
## Post creation stays on the bridge
|
|
113
|
+
|
|
114
|
+
`CREATE_POST_FROM_APP` and `PUBLISH_GENERATION_OUTPUTS` are **not** getting a plain REST equivalent, and this is
|
|
115
|
+
a decision rather than a backlog item.
|
|
116
|
+
|
|
117
|
+
The host confirmation is not a dialog an app could reproduce. The server returns a preview; the write echoes
|
|
118
|
+
`confirmedImageCount` **from that server preview**; and the host refuses the write on mismatch. A bare
|
|
119
|
+
`POST /posts` would let a block draw its own confirmation inside an iframe at an opaque origin, with nothing
|
|
120
|
+
binding what the viewer was shown to what gets written. Moving it would remove a consent control, not relocate
|
|
121
|
+
one.
|
|
122
|
+
|
|
123
|
+
If a REST surface is ever needed, the defensible shape is a route returning a short-lived server-signed publish
|
|
124
|
+
intent that host chrome redeems after showing the preview — so the host stays in the loop by construction.
|
|
125
|
+
|
|
126
|
+
`PUBLISH_GENERATION_OUTPUTS` is on `app.host` for exactly that reason: staying on the bridge is the decision,
|
|
127
|
+
and `app.host` is where a bridge message lives. Its own version of the binding is the index — the block names a
|
|
128
|
+
workflow and positions in it, and the host resolves the urls from the workflow it has verified the block owns,
|
|
129
|
+
so there is no url for a frame to supply. `CREATE_POST_FROM_APP` is not carried; nothing in the fleet calls it
|
|
130
|
+
that `publishGenerationOutputs` does not already serve.
|
|
131
|
+
|
|
26
132
|
## What a direct orchestrator call loses
|
|
27
133
|
|
|
28
134
|
Submitting through the host went through civitai's own `blocks.submitWorkflow`,
|
|
@@ -38,12 +144,35 @@ which added controls a direct call does not get:
|
|
|
38
144
|
## Host UI still carried
|
|
39
145
|
|
|
40
146
|
On `app.host`: `requestSignIn`, `download` (was `SAVE_IMAGE`),
|
|
41
|
-
`openResourcePicker`, `openBuzzPurchase`, `
|
|
42
|
-
|
|
147
|
+
`openResourcePicker`, `openBuzzPurchase`, `openImageUpload`,
|
|
148
|
+
`publishGenerationOutputs`, `resize`, `reportError`, `navigate` and
|
|
149
|
+
`onVisibilityChange`. Consent is `app.requestGrants` (was `requestConsent`).
|
|
150
|
+
|
|
151
|
+
`publishGenerationOutputs` keeps `usePublishGenerationOutputs`'s wire exactly —
|
|
152
|
+
`workflowId` plus `imageIndexes`, never a url — and drops one field. `title`
|
|
153
|
+
reached the host's validator and was then discarded before the mutation, so
|
|
154
|
+
sending it was a no-op end to end; a field that does nothing is not worth a
|
|
155
|
+
version commitment. Two client-side refusals are new, and both are for
|
|
156
|
+
something the host does silently: a missing `workflowId` is DROPPED with no
|
|
157
|
+
reply at all, which without a client deadline is a hang; and an `imageIndexes`
|
|
158
|
+
the host cannot read is STRIPPED, and a stripped `imageIndexes` means publish
|
|
159
|
+
every output.
|
|
160
|
+
|
|
161
|
+
`openImageUpload` differs in shape from `useImageUpload`, twice:
|
|
162
|
+
|
|
163
|
+
- **There is one display mode, the asynchronous one.** The hook also had a
|
|
164
|
+
blocking variant whose return looked moderated; here a public upload always
|
|
165
|
+
resolves a `PendingImage` and the verdict comes from its `scan()`, so no
|
|
166
|
+
caller ends up holding an image that looks cleared without having asked. A
|
|
167
|
+
host predating `asyncScan` replies with a moderated image instead, and that
|
|
168
|
+
is read as the verdict it already is — `scan()` answers rather than waiting
|
|
169
|
+
on a push no such host will send.
|
|
170
|
+
- **`scan()` has no deadline of its own.** The hook gave up after ten minutes
|
|
171
|
+
and called that a retryable error. Client deadlines left with all the others
|
|
172
|
+
(see *Behaviour* below) — pass a `signal` for the bound your app wants.
|
|
43
173
|
|
|
44
174
|
Not carried: `OPEN_CHECKPOINT_PICKER` (use `openResourcePicker` with
|
|
45
|
-
`resourceType: 'Checkpoint'`), `SET_USER_CHECKPOINT` (inert on a page)
|
|
46
|
-
`OPEN_IMAGE_UPLOAD` (image-only; the site's upload takes media).
|
|
175
|
+
`resourceType: 'Checkpoint'`), `SET_USER_CHECKPOINT` (inert on a page).
|
|
47
176
|
|
|
48
177
|
## Behaviour
|
|
49
178
|
|
|
@@ -61,9 +190,40 @@ For a block to use the API at all, civitai has to:
|
|
|
61
190
|
1. Mint an OAuth access token for the app's own client (`appblk-<slug>`) and
|
|
62
191
|
send it in `BLOCK_INIT` and `TOKEN_REFRESH`, instead of or beside the block
|
|
63
192
|
JWT. The auth hub currently bars `appblk-*` clients from its flows.
|
|
64
|
-
|
|
65
|
-
|
|
193
|
+
|
|
194
|
+
🔴 **That bar is deliberate security, not an oversight.** App-block clients exist only as the policy ceiling
|
|
195
|
+
for block-token minting; letting them drive the interactive authorization-code or device flows would mint a
|
|
196
|
+
real account bearer token, and an app-block owner could phish a viewer through the consent screen into
|
|
197
|
+
account takeover. The bar is enforced in four places, the load-bearing one being an empty `grants` list
|
|
198
|
+
written into the client row at approve time. **Removing it is re-opening a signed-off security finding, not
|
|
199
|
+
a config change.**
|
|
200
|
+
|
|
201
|
+
⚠ There may be no need to. The block JWT is **not** limited to `/api/v1/blocks/*` by any route or claim check
|
|
202
|
+
— it works wherever a handler is wrapped, and `GET /api/v1/models/{id}` is already dual-auth today. Widening
|
|
203
|
+
what the existing block token is accepted on may reach the same destination without touching OAuth at all.
|
|
204
|
+
The question that decides between the two paths is whether a block must act **without an open host page**:
|
|
205
|
+
the block JWT lives ~15 minutes and is refreshed by the host page's session, so the block holds no refresh
|
|
206
|
+
credential of its own. If background work is required, OAuth becomes necessary rather than optional.
|
|
207
|
+
|
|
208
|
+
2. ~~Let a browser send `Authorization` to `/api/v1` from the app's registered origins.~~
|
|
209
|
+
✅ **DONE** — shipped 2026-09-22 (civitai#5028). `Authorization` is named explicitly in the allowed-headers
|
|
210
|
+
list, because a `*` wildcard does not cover it. Note `/api/v1/blocks/*` was never affected; it has carried
|
|
211
|
+
its own origin-allowlisted CORS since May.
|
|
212
|
+
|
|
66
213
|
3. Add the `/api/v1` routes above as apps need them, and scope checks.
|
|
67
214
|
|
|
215
|
+
Partially done — shared storage, Buzz balance and batch images have landed; per-app-user storage
|
|
216
|
+
(`APP_STORAGE_*`) is the largest remaining gap at 5 of 7 fleet apps.
|
|
217
|
+
|
|
218
|
+
⚠ **Scope checks are a separate item and are not a prerequisite for the routes above.** Every block scope
|
|
219
|
+
these routes need already exists, is consent-described and is context-bound, so each route is scope-gated on
|
|
220
|
+
arrival. What is missing is OAuth `TokenScope` enforcement on the *general* `/api/v1` surface: it is applied
|
|
221
|
+
by a tRPC middleware only, and the REST wrappers resolve a bearer token's scope onto the request and then
|
|
222
|
+
never read it back. Five routes check a scope by hand; everything else does not.
|
|
223
|
+
|
|
68
224
|
And the orchestrator has to check that a workflow belongs to the caller before
|
|
69
225
|
reading, changing or deleting it, which it does not today.
|
|
226
|
+
|
|
227
|
+
⚠ **Sequencing note for anyone porting generation:** the `blocks.*` bridge path applies host-side checks that a
|
|
228
|
+
direct orchestrator call does not. Porting an app's generation calls off the bridge is therefore not a
|
|
229
|
+
transport-only change, and should be sequenced after the item above rather than before it.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Civitai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -11,16 +11,20 @@ import { initialize } from '@civitai/sdk';
|
|
|
11
11
|
const app = await initialize();
|
|
12
12
|
|
|
13
13
|
const picked = await app.host.openResourcePicker({ resourceType: 'Checkpoint' });
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
14
|
+
if (picked && (await app.requestGrants(['ai:write:budgeted']))) {
|
|
15
|
+
const { air } = await app.site.get<{ air: string }>(`model-versions/mini/${picked.versionId}`);
|
|
16
|
+
|
|
17
|
+
const submitted = await app.orchestration.submitWorkflow({
|
|
18
|
+
steps: [
|
|
19
|
+
{
|
|
20
|
+
$type: 'textToImage',
|
|
21
|
+
input: { model: air, prompt: 'A lighthouse at dusk', cfgScale: 7, seed: 1234 },
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
const workflow = await app.orchestration.waitForWorkflow(submitted.id!);
|
|
26
|
+
render(workflow);
|
|
27
|
+
}
|
|
24
28
|
```
|
|
25
29
|
|
|
26
30
|
An app calls the Civitai API and the orchestrator as the viewer, with a token.
|
|
@@ -92,6 +96,50 @@ const images = await app.site.get('images', { query: { limit: 20, username: 'civ
|
|
|
92
96
|
A `401` is retried once with a fresh token. Any other failure is an `ApiError`
|
|
93
97
|
carrying `status`, the parsed `body`, and the server's own message.
|
|
94
98
|
|
|
99
|
+
## App storage
|
|
100
|
+
|
|
101
|
+
`app.storage` is the viewer's own key/value store, scoped to this app and this
|
|
102
|
+
block instance. It needs the block token the host mints — an app holding an
|
|
103
|
+
OAuth access token has no per-viewer app storage — and an anonymous viewer is
|
|
104
|
+
refused, so gate on `app.viewer` rather than reading an empty page as "nothing
|
|
105
|
+
stored".
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
await app.storage.set('draft:latest', { prompt, steps: 30 });
|
|
109
|
+
const draft = await app.storage.get<Draft>('draft:latest'); // null when unset
|
|
110
|
+
const { keys, nextCursor } = await app.storage.list({ prefix: 'draft:' });
|
|
111
|
+
const quota = await app.storage.getQuota();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Three properties the surface is built around:
|
|
115
|
+
|
|
116
|
+
- **Every failure rejects.** Nothing here resolves to mean "not written", and
|
|
117
|
+
nothing resolves to mean "could not read". A malformed success throws a
|
|
118
|
+
`CivitaiError`; a refusal is an `ApiError` carrying `status`. A caller that
|
|
119
|
+
must not act on a partial view branches on the rejection, never on an empty
|
|
120
|
+
result.
|
|
121
|
+
- **`nextCursor` is present exactly when there may be more rows**, and is passed
|
|
122
|
+
through untouched. Its absence is your proof a scan completed. Page it
|
|
123
|
+
yourself, with a bound — the client adds no iterator, because the truncation
|
|
124
|
+
policy is the caller's to choose.
|
|
125
|
+
- **`set`'s `sizeBytes` is the wire unit; `getQuota`'s `usedBytes` is the stored
|
|
126
|
+
unit.** They are not a fixed multiple — measured, a numeric-heavy value stores
|
|
127
|
+
up to 44.4x its wire size — so summing `sizeBytes` under-counts your quota.
|
|
128
|
+
`getQuota()` is the authority, and it reports neither the key-length cap nor
|
|
129
|
+
the per-value cap.
|
|
130
|
+
|
|
131
|
+
A write refused for size or quota arrives as `413`, whichever ceiling fired — the
|
|
132
|
+
server names which one only in prose, so test the status, not the message:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
try {
|
|
136
|
+
await app.storage.set('draft:latest', huge);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof ApiError && error.status === 413) askTheUserToFreeSpace();
|
|
139
|
+
else throw error;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
95
143
|
## The orchestrator
|
|
96
144
|
|
|
97
145
|
Steps are typed by `$type`: `input` is checked against that step's own input,
|
|
@@ -144,12 +192,103 @@ wider scope — and refuses without one.
|
|
|
144
192
|
| `download({ url, filename })` | Saves to the viewer's device — a sandboxed frame cannot |
|
|
145
193
|
| `openResourcePicker({ resourceType })` | civitai's own picker; resolves the one pick, or `null` |
|
|
146
194
|
| `openBuzzPurchase({ suggestedAmount })` | The purchase flow; resolves `{ purchased }` |
|
|
195
|
+
| `openImageUpload()` | The upload modal; resolves a `PendingImage`, or `null` |
|
|
196
|
+
| `openImageUpload({ purpose: 'generationSource' })` | The same modal for a private img2img source; resolves `{ url, width, height }` |
|
|
197
|
+
| `publishGenerationOutputs({ workflowId, imageIndexes })` | Publishes your own workflow's outputs, behind the host's confirmation; resolves the new image ids |
|
|
147
198
|
|
|
148
199
|
Host failures reject with a `BridgeError` carrying a `code` (`forbidden`,
|
|
149
200
|
`unauthenticated`, `rate-limited`, …). Timeouts are the host's to set; pass a
|
|
150
201
|
`signal` to cancel. `ApiError` and `BridgeError` both extend `CivitaiError`, so
|
|
151
202
|
one `catch` can tell a refusal from a bug.
|
|
152
203
|
|
|
204
|
+
### Uploading an image
|
|
205
|
+
|
|
206
|
+
The host owns the upload: it opens its own modal, takes the file through the
|
|
207
|
+
viewer's session and hands back what it stored. The frame never sees the bytes.
|
|
208
|
+
|
|
209
|
+
A **public** image is moderated *after* it is stored, so `openImageUpload()`
|
|
210
|
+
resolves as soon as the image exists and the verdict comes from `scan()`:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
const app = await initialize();
|
|
214
|
+
|
|
215
|
+
const image = await app.host.openImageUpload();
|
|
216
|
+
if (image) {
|
|
217
|
+
showTheAuthorTheirOwnPreview(image.url);
|
|
218
|
+
|
|
219
|
+
const verdict = await image.scan({ signal: AbortSignal.timeout(600_000) });
|
|
220
|
+
if (verdict.status === 'scanned') publish(verdict.image);
|
|
221
|
+
else if (verdict.status === 'blocked') tellThemWhy(verdict.reason);
|
|
222
|
+
else offerToTryAgain(verdict.message);
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
🔴 `scanned` is the only verdict that clears the image for anyone but its
|
|
227
|
+
author. `blocked` is the host refusing it; `error` is the host not answering —
|
|
228
|
+
neither is a pass, so branch on `scanned`, never on "not blocked". `scan()`
|
|
229
|
+
waits as long as the host takes and this package imposes no deadline of its
|
|
230
|
+
own, which is why the snippet passes a signal.
|
|
231
|
+
|
|
232
|
+
A **generation source** is private and unscanned here — the orchestrator scans
|
|
233
|
+
it when the workflow runs — so it needs no verdict, and comes back as a url and
|
|
234
|
+
the image's real dimensions. A step takes the two separately: `sourceImage` is
|
|
235
|
+
the url, and the dimensions are the step's own:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
const app = await initialize();
|
|
239
|
+
|
|
240
|
+
const source = await app.host.openImageUpload({ purpose: 'generationSource' });
|
|
241
|
+
if (source) {
|
|
242
|
+
await app.orchestration.submitWorkflow({
|
|
243
|
+
steps: [
|
|
244
|
+
{
|
|
245
|
+
$type: 'textToImage',
|
|
246
|
+
input: {
|
|
247
|
+
model: 'urn:air:sdxl:checkpoint:civitai:101055@128078',
|
|
248
|
+
prompt: 'the same lighthouse, at dawn',
|
|
249
|
+
cfgScale: 7,
|
|
250
|
+
seed: 1234,
|
|
251
|
+
sourceImage: source.url,
|
|
252
|
+
width: source.width,
|
|
253
|
+
height: source.height,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Publishing a generation
|
|
262
|
+
|
|
263
|
+
Outputs of a workflow your app ran can become public images, and the host asks
|
|
264
|
+
the viewer first. Needs `ai:write:budgeted` — an app trusted to spend their
|
|
265
|
+
Buzz on a generation is trusted to publish what it produced.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const app = await initialize();
|
|
269
|
+
|
|
270
|
+
const imageIds = await app.host.publishGenerationOutputs(
|
|
271
|
+
{ workflowId, imageIndexes: [0, 2] },
|
|
272
|
+
{ signal: AbortSignal.timeout(600_000) },
|
|
273
|
+
);
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
🔴 **Outputs are named by index, never by url.** The host re-derives that this
|
|
277
|
+
viewer and this app own the workflow and resolves the urls itself — a frame at
|
|
278
|
+
an opaque origin naming its own blob to publish would be a different feature
|
|
279
|
+
entirely. Omit `imageIndexes` to publish every output; an unusable list is
|
|
280
|
+
refused here rather than sent, because the host reads one it cannot parse as
|
|
281
|
+
*publish everything*.
|
|
282
|
+
|
|
283
|
+
The host holds its confirmation in front of a person, so this waits as long as
|
|
284
|
+
they take and nothing here cuts it short — pass a `signal`, as above, for the
|
|
285
|
+
bound your app wants. A viewer who declines rejects the call.
|
|
286
|
+
|
|
287
|
+
⚠ Publishing is best-effort per image: an output that fails is skipped rather
|
|
288
|
+
than failing the call, so `imageIds` can be **shorter** than what you selected
|
|
289
|
+
and nothing says which index dropped. Compare lengths; do not pair ids to
|
|
290
|
+
indexes.
|
|
291
|
+
|
|
153
292
|
## Parent origins
|
|
154
293
|
|
|
155
294
|
Messages are accepted only from an allowlisted parent origin. The allowlist is
|
|
@@ -167,6 +306,24 @@ entry.
|
|
|
167
306
|
`@civitai/sdk/testing` has `createFakeTransport()`: pass it as
|
|
168
307
|
`initialize({ transport })` and script the host's answers.
|
|
169
308
|
|
|
309
|
+
There is no published fake for app storage, on purpose. The seam is `fetch`, so
|
|
310
|
+
pass your own `fetch` to `initialize({ token, fetch })` and the client's URLs,
|
|
311
|
+
bodies, statuses and date revival are all real — which a fake replacing the
|
|
312
|
+
`storage` client would not be. Two things to get right in one you write:
|
|
313
|
+
|
|
314
|
+
- **Make the page size small** (2 or 3, not the server's 50). A page that holds
|
|
315
|
+
every fixture is exactly how a caller that never forwards `cursor` passes a
|
|
316
|
+
whole green suite.
|
|
317
|
+
- **Put `updatedAt` on the wire as an ISO string**, as `res.json()` does. Hand
|
|
318
|
+
the client a `Date` and its revival becomes unobservable, since
|
|
319
|
+
`new Date(aDate)` is a `Date`.
|
|
320
|
+
|
|
321
|
+
This SDK's own suite keeps one at
|
|
322
|
+
[`test/support/fake-app-storage.ts`](./test/support/fake-app-storage.ts); it is
|
|
323
|
+
not exported, because four of the five fleet apps that store per-viewer state
|
|
324
|
+
need knobs it does not have (injected latency, prefix-targeted refusals, quota
|
|
325
|
+
overrides, a cursor it ignores, a read that never settles). Copy it if it helps.
|
|
326
|
+
|
|
170
327
|
## Checks
|
|
171
328
|
|
|
172
329
|
- `npm run api` regenerates [`api/public-api.md`](./api/public-api.md) from the
|
package/dist/app/index.d.ts
CHANGED
|
@@ -4,9 +4,19 @@ import { type Host } from '../host/index.js';
|
|
|
4
4
|
import { type OrchestrationClient } from '../orchestration/index.js';
|
|
5
5
|
import { type GrantOptions, type Scope, type TokenOptions, type TokenSessionOptions } from '../session/index.js';
|
|
6
6
|
import { type SiteClient } from '../site/index.js';
|
|
7
|
+
import { type StorageClient } from '../storage/index.js';
|
|
7
8
|
export interface AppClient {
|
|
8
9
|
/** The public Civitai REST API (`/api/v1`), as the viewer. */
|
|
9
10
|
readonly site: SiteClient;
|
|
11
|
+
/**
|
|
12
|
+
* The viewer's own per-app key/value store.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 Requires the block token the host mints: an app that authenticated with
|
|
15
|
+
* an OAuth access token has no per-viewer app storage, and every call here is
|
|
16
|
+
* refused. An anonymous viewer is refused too — gate on `viewer` rather than
|
|
17
|
+
* reading an empty result as "nothing stored".
|
|
18
|
+
*/
|
|
19
|
+
readonly storage: StorageClient;
|
|
10
20
|
/** The orchestrator's workflows, as the viewer. */
|
|
11
21
|
readonly orchestration: OrchestrationClient;
|
|
12
22
|
/** Asks for more scopes. `false` when they cannot be granted — a refusal is an answer. */
|
package/dist/app/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createHttp } from '../http/index.js';
|
|
|
5
5
|
import { createOrchestrationClient, DEFAULT_ORCHESTRATION_URL, } from '../orchestration/index.js';
|
|
6
6
|
import { createTokenSession, } from '../session/index.js';
|
|
7
7
|
import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
|
|
8
|
+
import { createStorageClient } from '../storage/index.js';
|
|
8
9
|
/** Long enough for a slow host page; short enough that a block opened on its own says so. */
|
|
9
10
|
const DEFAULT_INIT_TIMEOUT_MS = 10_000;
|
|
10
11
|
export async function initialize(options = {}) {
|
|
@@ -33,8 +34,12 @@ export async function initialize(options = {}) {
|
|
|
33
34
|
}
|
|
34
35
|
function createAppClient(session, options) {
|
|
35
36
|
const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
|
|
37
|
+
// One instance for both site-hosted surfaces: `storage`'s routes live under
|
|
38
|
+
// the same base URL, so a `siteUrl` override redirects them together.
|
|
39
|
+
const siteHttp = http(options.siteUrl ?? DEFAULT_SITE_URL);
|
|
36
40
|
return {
|
|
37
|
-
site: createSiteClient(
|
|
41
|
+
site: createSiteClient(siteHttp),
|
|
42
|
+
storage: createStorageClient(siteHttp),
|
|
38
43
|
orchestration: createOrchestrationClient(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL)),
|
|
39
44
|
requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
|
|
40
45
|
getToken: (opts) => session.getToken(opts),
|
|
@@ -27,7 +27,9 @@ function fromLegacyReply(reply) {
|
|
|
27
27
|
*/
|
|
28
28
|
const LEGACY_REPLIES = {
|
|
29
29
|
OPEN_BUZZ_PURCHASE: 'BUZZ_PURCHASE_RESULT',
|
|
30
|
+
OPEN_IMAGE_UPLOAD: 'IMAGE_UPLOAD_RESULT',
|
|
30
31
|
OPEN_RESOURCE_PICKER: 'RESOURCE_PICKER_RESULT',
|
|
32
|
+
PUBLISH_GENERATION_OUTPUTS: 'PUBLISH_RESULT',
|
|
31
33
|
REQUEST_TOKEN: 'TOKEN_REFRESH_RESPONSE',
|
|
32
34
|
SAVE_IMAGE: 'SAVE_IMAGE_RESULT',
|
|
33
35
|
};
|
package/dist/host/index.d.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { type BlockTransport } from '../core/transport.js';
|
|
2
2
|
import type { Session } from '../session/index.js';
|
|
3
|
-
import type { DownloadRequest, PickedResource, ResourcePickerType } from './protocol.js';
|
|
4
|
-
export type { ConsentRefusal, DownloadRequest, PickedResource, ResourcePickerType, } from './protocol.js';
|
|
3
|
+
import type { DownloadRequest, ImageScanResult, PickedResource, ResourcePickerType, SourceImage } from './protocol.js';
|
|
4
|
+
export type { ConsentRefusal, DownloadRequest, ImageScanResult, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './protocol.js';
|
|
5
5
|
export interface HostCallOptions {
|
|
6
6
|
signal?: AbortSignal;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* An upload the host has stored but not yet finished moderating. The image
|
|
10
|
+
* exists and its author can see it; nobody else may until `scan()` answers.
|
|
11
|
+
*/
|
|
12
|
+
export interface PendingImage {
|
|
13
|
+
imageId: number;
|
|
14
|
+
/** The author's own preview. Not for any other viewer until `scan()` says `scanned`. */
|
|
15
|
+
url: string;
|
|
16
|
+
/**
|
|
17
|
+
* The host's verdict on this upload. Resolves once the host reaches one and
|
|
18
|
+
* returns the same answer to every later call, so it is safe to re-read.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 It waits as long as the host takes — the host is what bounds the scan,
|
|
21
|
+
* and this package sets no deadline of its own. Pass a `signal` if your app
|
|
22
|
+
* needs one; aborting gives up on the verdict, it does not decide it.
|
|
23
|
+
*/
|
|
24
|
+
scan(opts?: HostCallOptions): Promise<ImageScanResult>;
|
|
25
|
+
}
|
|
8
26
|
/** Asking the host page to show its own UI. Data goes over the API, not here. */
|
|
9
27
|
export interface Host {
|
|
10
28
|
/** Resizes the frame, clamped to the manifest's bounds. */
|
|
@@ -41,6 +59,48 @@ export interface Host {
|
|
|
41
59
|
}, opts?: HostCallOptions): Promise<{
|
|
42
60
|
purchased: boolean;
|
|
43
61
|
}>;
|
|
62
|
+
/**
|
|
63
|
+
* civitai's own upload modal. The bytes go through the host's session, never
|
|
64
|
+
* through the frame, and `null` means the viewer closed it without uploading.
|
|
65
|
+
*
|
|
66
|
+
* `purpose: 'generationSource'` uploads a PRIVATE img2img source: unscanned
|
|
67
|
+
* here, scanned by the orchestrator when the workflow runs.
|
|
68
|
+
*/
|
|
69
|
+
openImageUpload(args: {
|
|
70
|
+
purpose: 'generationSource';
|
|
71
|
+
}, opts?: HostCallOptions): Promise<SourceImage | null>;
|
|
72
|
+
/**
|
|
73
|
+
* A PUBLIC image. The host stores it and moderates it afterwards, so this
|
|
74
|
+
* resolves with a {@link PendingImage} — the image, plus the `scan()` that
|
|
75
|
+
* answers whether anyone but its author may see it.
|
|
76
|
+
*/
|
|
77
|
+
openImageUpload(args?: {
|
|
78
|
+
purpose?: 'display';
|
|
79
|
+
}, opts?: HostCallOptions): Promise<PendingImage | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Publishes outputs of ONE of this app's own workflows as public images, and
|
|
82
|
+
* resolves with the ids of the rows the host created. Needs
|
|
83
|
+
* `ai:write:budgeted`: an app trusted to spend the viewer's Buzz on a
|
|
84
|
+
* generation is trusted to publish what that generation produced.
|
|
85
|
+
*
|
|
86
|
+
* 🔴 Outputs are named by INDEX into the workflow, never by url. The host
|
|
87
|
+
* re-derives that this viewer and this app own `workflowId`, then resolves
|
|
88
|
+
* the urls itself — which is the whole guarantee, because a frame at an
|
|
89
|
+
* opaque origin naming its own blob to publish would be a different feature.
|
|
90
|
+
*
|
|
91
|
+
* The host shows the viewer a confirmation first and answers only when they
|
|
92
|
+
* act, so this waits on a person. Nothing here bounds that wait; pass a
|
|
93
|
+
* `signal` for the bound your app wants.
|
|
94
|
+
*
|
|
95
|
+
* ⚠ Publishing is best-effort per image. An output that fails to publish is
|
|
96
|
+
* skipped rather than failing the call, so `imageIds` can be SHORTER than
|
|
97
|
+
* the selection and nothing says which index dropped. Compare lengths rather
|
|
98
|
+
* than pairing ids to indexes.
|
|
99
|
+
*/
|
|
100
|
+
publishGenerationOutputs(args: {
|
|
101
|
+
workflowId: string;
|
|
102
|
+
imageIndexes?: number[];
|
|
103
|
+
}, opts?: HostCallOptions): Promise<number[]>;
|
|
44
104
|
}
|
|
45
105
|
export declare function createHost(transport: BlockTransport): Host;
|
|
46
106
|
/** The token the host minted for this block, kept current as the host rotates it. */
|
package/dist/host/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BridgeError } from '../core/errors.js';
|
|
1
2
|
import { createCaller, createListener, createNotifier } from '../core/messaging.js';
|
|
2
3
|
import { tokenFromWrapped } from '../core/transport.js';
|
|
3
4
|
const call = createCaller();
|
|
@@ -44,7 +45,202 @@ export function createHost(transport) {
|
|
|
44
45
|
const { purchased } = await call('OPEN_BUZZ_PURCHASE', args, { ...opts, transport });
|
|
45
46
|
return { purchased };
|
|
46
47
|
},
|
|
48
|
+
async publishGenerationOutputs(args, opts = {}) {
|
|
49
|
+
const { imageIds } = await call('PUBLISH_GENERATION_OUTPUTS', publishParams(args), {
|
|
50
|
+
...opts,
|
|
51
|
+
transport,
|
|
52
|
+
});
|
|
53
|
+
// The host spells "no failure" as `error: ''`, which the transport reads
|
|
54
|
+
// as a success — so a reply carrying neither the ids nor a message gets
|
|
55
|
+
// this far, and returning it would resolve `undefined` from a promise
|
|
56
|
+
// that says `number[]`. A caller reading that as "nothing published"
|
|
57
|
+
// would be guessing: images may well have been.
|
|
58
|
+
if (!Array.isArray(imageIds) || !imageIds.every((id) => typeof id === 'number')) {
|
|
59
|
+
throw new BridgeError('malformed', 'PUBLISH_GENERATION_OUTPUTS', 'PUBLISH_GENERATION_OUTPUTS reply carried no image ids. Whether anything ' +
|
|
60
|
+
'was published is unknown; read the workflow back rather than assuming.');
|
|
61
|
+
}
|
|
62
|
+
return imageIds;
|
|
63
|
+
},
|
|
64
|
+
// Cast scoped to this one member: the interface overloads it so each
|
|
65
|
+
// `purpose` names its own return, which one implementation signature cannot
|
|
66
|
+
// express. Every other member here is still checked against `Host`.
|
|
67
|
+
openImageUpload: ((args = {}, opts = {}) => args.purpose === 'generationSource'
|
|
68
|
+
? openSourceUpload(transport, opts)
|
|
69
|
+
: openDisplayUpload(transport, opts)),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Everything that goes on the `PUBLISH_GENERATION_OUTPUTS` wire, built field by
|
|
74
|
+
* field.
|
|
75
|
+
*
|
|
76
|
+
* 🔴 NAMED FIELDS, NEVER A SPREAD. The message's guarantee is that a block
|
|
77
|
+
* cannot say WHICH bytes to publish — it names a workflow it owns and indexes
|
|
78
|
+
* into it, and the host resolves the urls. Spreading the caller's object here
|
|
79
|
+
* would put whatever else it carries on the wire, so the one line that makes
|
|
80
|
+
* `url` unreachable is the two assignments below being the only ones.
|
|
81
|
+
*
|
|
82
|
+
* The two refusals are hangs and escalations the host cannot save us from:
|
|
83
|
+
*
|
|
84
|
+
* - A missing `workflowId` is DROPPED by the host with no reply at all, and
|
|
85
|
+
* nothing in this package bounds a request, so the call would simply never
|
|
86
|
+
* settle.
|
|
87
|
+
* - An `imageIndexes` the host cannot read — empty, or holding anything that is
|
|
88
|
+
* not a whole index — is stripped, and a stripped `imageIndexes` means
|
|
89
|
+
* PUBLISH EVERY OUTPUT. "Publish these two" quietly becoming "publish all
|
|
90
|
+
* twenty" is not a failure a viewer can take back.
|
|
91
|
+
*/
|
|
92
|
+
function publishParams(args) {
|
|
93
|
+
const { workflowId, imageIndexes } = args;
|
|
94
|
+
if (typeof workflowId !== 'string' || workflowId === '') {
|
|
95
|
+
throw new BridgeError('invalid', 'PUBLISH_GENERATION_OUTPUTS', 'publishGenerationOutputs needs the workflowId of a workflow this app ran.');
|
|
96
|
+
}
|
|
97
|
+
if (imageIndexes === undefined)
|
|
98
|
+
return { workflowId };
|
|
99
|
+
if (!Array.isArray(imageIndexes) ||
|
|
100
|
+
imageIndexes.length === 0 ||
|
|
101
|
+
!imageIndexes.every((index) => Number.isInteger(index) && index >= 0)) {
|
|
102
|
+
throw new BridgeError('invalid', 'PUBLISH_GENERATION_OUTPUTS', 'imageIndexes must be a non-empty list of output indexes. The host reads an ' +
|
|
103
|
+
'unusable list as "publish every output", so this refuses rather than publish ' +
|
|
104
|
+
'more than you asked for.');
|
|
105
|
+
}
|
|
106
|
+
return { workflowId, imageIndexes: [...imageIndexes] };
|
|
107
|
+
}
|
|
108
|
+
async function openSourceUpload(transport, opts) {
|
|
109
|
+
const { selected } = await call('OPEN_IMAGE_UPLOAD', { purpose: 'generationSource' }, { ...opts, transport });
|
|
110
|
+
return asSourceImage(selected);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The public-image upload. Two things make it more than one `call`:
|
|
114
|
+
*
|
|
115
|
+
* The verdict is a PUSH the host sends on its own schedule, so the listener has
|
|
116
|
+
* to be attached BEFORE the request goes out — a scan that finishes quickly can
|
|
117
|
+
* land before the reply is read, and a verdict nobody heard is a verdict lost.
|
|
118
|
+
*
|
|
119
|
+
* And a host that does not know `asyncScan` answers the OLD way: it blocks its
|
|
120
|
+
* modal on the scan and replies with a moderated image. That reply is a verdict
|
|
121
|
+
* already reached, so it is stored as one; a caller written against `scan()`
|
|
122
|
+
* gets its answer rather than waiting on a push no such host will ever send.
|
|
123
|
+
*/
|
|
124
|
+
function openDisplayUpload(transport, opts) {
|
|
125
|
+
let verdict;
|
|
126
|
+
let imageId;
|
|
127
|
+
/** Verdicts that arrived before the reply named our `imageId`, keyed by theirs. */
|
|
128
|
+
const early = new Map();
|
|
129
|
+
const waiters = new Set();
|
|
130
|
+
const settle = (result) => {
|
|
131
|
+
verdict = result;
|
|
132
|
+
off();
|
|
133
|
+
for (const waiter of [...waiters])
|
|
134
|
+
waiter(result);
|
|
135
|
+
waiters.clear();
|
|
136
|
+
};
|
|
137
|
+
const off = on(transport, 'IMAGE_SCAN_RESOLVED', (push) => {
|
|
138
|
+
if (verdict !== undefined)
|
|
139
|
+
return; // the host emits once; honour the first
|
|
140
|
+
if (typeof push?.imageId !== 'number')
|
|
141
|
+
return;
|
|
142
|
+
if (imageId === undefined)
|
|
143
|
+
early.set(push.imageId, asScanResult(push.result));
|
|
144
|
+
else if (push.imageId === imageId)
|
|
145
|
+
settle(asScanResult(push.result));
|
|
146
|
+
});
|
|
147
|
+
const handle = (id, url) => {
|
|
148
|
+
imageId = id;
|
|
149
|
+
const buffered = early.get(id);
|
|
150
|
+
if (buffered !== undefined && verdict === undefined)
|
|
151
|
+
settle(buffered);
|
|
152
|
+
return {
|
|
153
|
+
imageId: id,
|
|
154
|
+
url,
|
|
155
|
+
scan: ({ signal } = {}) => new Promise((resolve, reject) => {
|
|
156
|
+
if (verdict !== undefined)
|
|
157
|
+
return resolve(verdict);
|
|
158
|
+
if (signal?.aborted)
|
|
159
|
+
return reject(signal.reason);
|
|
160
|
+
const deliver = (result) => {
|
|
161
|
+
signal?.removeEventListener('abort', onAbort);
|
|
162
|
+
resolve(result);
|
|
163
|
+
};
|
|
164
|
+
const onAbort = () => {
|
|
165
|
+
waiters.delete(deliver);
|
|
166
|
+
reject(signal.reason);
|
|
167
|
+
};
|
|
168
|
+
waiters.add(deliver);
|
|
169
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
47
172
|
};
|
|
173
|
+
return (async () => {
|
|
174
|
+
let selected;
|
|
175
|
+
try {
|
|
176
|
+
({ selected } = await call('OPEN_IMAGE_UPLOAD', { asyncScan: true }, { ...opts, transport }));
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
off();
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
const pending = asPendingUpload(selected);
|
|
183
|
+
if (pending)
|
|
184
|
+
return handle(pending.imageId, pending.url);
|
|
185
|
+
// A host that ignored `asyncScan` and scanned before replying.
|
|
186
|
+
const moderated = asUploadedImage(selected);
|
|
187
|
+
if (moderated) {
|
|
188
|
+
const image = handle(moderated.imageId, moderated.url);
|
|
189
|
+
if (verdict === undefined)
|
|
190
|
+
settle({ status: 'scanned', image: moderated });
|
|
191
|
+
return image;
|
|
192
|
+
}
|
|
193
|
+
// Dismissed, or a reply this flow cannot read as an image. Never invent a
|
|
194
|
+
// handle: its `scan()` would have nothing to answer with.
|
|
195
|
+
off();
|
|
196
|
+
return null;
|
|
197
|
+
})();
|
|
198
|
+
}
|
|
199
|
+
function asSourceImage(value) {
|
|
200
|
+
const v = value;
|
|
201
|
+
return typeof v?.url === 'string' && typeof v.width === 'number' && typeof v.height === 'number'
|
|
202
|
+
? { url: v.url, width: v.width, height: v.height }
|
|
203
|
+
: null;
|
|
204
|
+
}
|
|
205
|
+
function asPendingUpload(value) {
|
|
206
|
+
const v = value;
|
|
207
|
+
return v?.status === 'pending' && typeof v.imageId === 'number' && typeof v.url === 'string'
|
|
208
|
+
? { imageId: v.imageId, url: v.url }
|
|
209
|
+
: null;
|
|
210
|
+
}
|
|
211
|
+
function asUploadedImage(value) {
|
|
212
|
+
const v = value;
|
|
213
|
+
return typeof v?.imageId === 'number' &&
|
|
214
|
+
typeof v.url === 'string' &&
|
|
215
|
+
typeof v.nsfwLevel === 'number' &&
|
|
216
|
+
typeof v.contentRating === 'string'
|
|
217
|
+
? {
|
|
218
|
+
imageId: v.imageId,
|
|
219
|
+
url: v.url,
|
|
220
|
+
nsfwLevel: v.nsfwLevel,
|
|
221
|
+
contentRating: v.contentRating,
|
|
222
|
+
}
|
|
223
|
+
: null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 🔴 The only way into {@link ImageScanResult}, and it fails CLOSED in both
|
|
227
|
+
* directions: `blocked` is kept exactly as the host sent it, and nothing
|
|
228
|
+
* becomes `scanned` without a readable image to back the claim. A verdict this
|
|
229
|
+
* cannot read is an `error` — which is not a pass — rather than dropped, since
|
|
230
|
+
* a dropped verdict leaves `scan()` waiting on a push the host has already sent.
|
|
231
|
+
*/
|
|
232
|
+
function asScanResult(value) {
|
|
233
|
+
const v = value;
|
|
234
|
+
if (v?.status === 'blocked') {
|
|
235
|
+
return { status: 'blocked', ...(typeof v.reason === 'string' ? { reason: v.reason } : {}) };
|
|
236
|
+
}
|
|
237
|
+
if (v?.status === 'scanned') {
|
|
238
|
+
const image = asUploadedImage(v.image);
|
|
239
|
+
return image
|
|
240
|
+
? { status: 'scanned', image }
|
|
241
|
+
: { status: 'error', message: 'the host reported a scan with no image' };
|
|
242
|
+
}
|
|
243
|
+
return { status: 'error', ...(typeof v?.message === 'string' ? { message: v.message } : {}) };
|
|
48
244
|
}
|
|
49
245
|
/** The token the host minted for this block, kept current as the host rotates it. */
|
|
50
246
|
export function createHostSession(transport) {
|
package/dist/host/protocol.d.ts
CHANGED
|
@@ -21,6 +21,50 @@ export interface DownloadRequest {
|
|
|
21
21
|
}
|
|
22
22
|
/** Why consent can never be granted in this environment. */
|
|
23
23
|
export type ConsentRefusal = 'ungrantable';
|
|
24
|
+
/**
|
|
25
|
+
* An uploaded image the host has finished moderating. `nsfwLevel` and
|
|
26
|
+
* `contentRating` are the host's verdict, so a block renders by them rather
|
|
27
|
+
* than judging the image itself.
|
|
28
|
+
*/
|
|
29
|
+
export interface UploadedImage {
|
|
30
|
+
imageId: number;
|
|
31
|
+
/** The host's browsing-level bitmask for this image. */
|
|
32
|
+
nsfwLevel: number;
|
|
33
|
+
contentRating: 'g' | 'pg' | 'pg13' | 'r' | 'x';
|
|
34
|
+
url: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A private img2img source. The host stores it UNSCANNED — the orchestrator
|
|
38
|
+
* scans it when the workflow runs — so it carries no moderation verdict and
|
|
39
|
+
* belongs in a generation step, not on a surface other viewers can see.
|
|
40
|
+
*
|
|
41
|
+
* A step takes the url and the dimensions separately: `sourceImage` is the url,
|
|
42
|
+
* and `width`/`height` are the step's own.
|
|
43
|
+
*/
|
|
44
|
+
export interface SourceImage {
|
|
45
|
+
url: string;
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The host's moderation verdict on an upload, reached after the upload itself
|
|
51
|
+
* has already resolved.
|
|
52
|
+
*
|
|
53
|
+
* 🔴 `scanned` is the ONLY value that clears an image for a surface other
|
|
54
|
+
* viewers can see. `blocked` is the host refusing it, and `error` is the host
|
|
55
|
+
* declining to answer — neither is a pass, and a caller that reads "not
|
|
56
|
+
* blocked" as clean has inverted the guarantee.
|
|
57
|
+
*/
|
|
58
|
+
export type ImageScanResult = {
|
|
59
|
+
status: 'scanned';
|
|
60
|
+
image: UploadedImage;
|
|
61
|
+
} | {
|
|
62
|
+
status: 'blocked';
|
|
63
|
+
reason?: string;
|
|
64
|
+
} | {
|
|
65
|
+
status: 'error';
|
|
66
|
+
message?: string;
|
|
67
|
+
};
|
|
24
68
|
export type HostRequests = {
|
|
25
69
|
SAVE_IMAGE: {
|
|
26
70
|
params: DownloadRequest;
|
|
@@ -46,6 +90,50 @@ export type HostRequests = {
|
|
|
46
90
|
newBalance?: number;
|
|
47
91
|
};
|
|
48
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* The host's own upload modal; the frame never handles the bytes.
|
|
95
|
+
*
|
|
96
|
+
* `purpose` is sent ONLY for `'generationSource'`. The host normalises an
|
|
97
|
+
* absent — or unrecognised — purpose to `'display'`, so omitting it keeps the
|
|
98
|
+
* display call byte-identical to the wire hosts have answered all along.
|
|
99
|
+
*
|
|
100
|
+
* `asyncScan` makes the host resolve on persist and stream the verdict as an
|
|
101
|
+
* `IMAGE_SCAN_RESOLVED` push. The host ignores it for `'generationSource'`,
|
|
102
|
+
* which has no scan at all.
|
|
103
|
+
*/
|
|
104
|
+
OPEN_IMAGE_UPLOAD: {
|
|
105
|
+
params: {
|
|
106
|
+
purpose?: 'generationSource';
|
|
107
|
+
asyncScan?: true;
|
|
108
|
+
};
|
|
109
|
+
result: {
|
|
110
|
+
selected?: UploadedImage | SourceImage | {
|
|
111
|
+
status: 'pending';
|
|
112
|
+
imageId: number;
|
|
113
|
+
url: string;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Publish outputs of ONE of this app's own workflows as scanned public
|
|
119
|
+
* images, behind the host's own confirmation.
|
|
120
|
+
*
|
|
121
|
+
* 🔴 `imageIndexes` INDEX the workflow's outputs, and there is no url on this
|
|
122
|
+
* message — there must never be one. The host re-derives ownership of
|
|
123
|
+
* `workflowId` from the token and resolves the orchestrator urls itself,
|
|
124
|
+
* which is what stops a frame at an opaque origin from naming an arbitrary
|
|
125
|
+
* blob to publish. A shape that accepted a url would be a different, weaker
|
|
126
|
+
* feature wearing this one's name.
|
|
127
|
+
*/
|
|
128
|
+
PUBLISH_GENERATION_OUTPUTS: {
|
|
129
|
+
params: {
|
|
130
|
+
workflowId: string;
|
|
131
|
+
imageIndexes?: number[];
|
|
132
|
+
};
|
|
133
|
+
result: {
|
|
134
|
+
imageIds: number[];
|
|
135
|
+
};
|
|
136
|
+
};
|
|
49
137
|
REQUEST_TOKEN: {
|
|
50
138
|
params: {
|
|
51
139
|
blockInstanceId: string;
|
|
@@ -83,4 +171,17 @@ export type HostPushes = {
|
|
|
83
171
|
reason: ConsentRefusal;
|
|
84
172
|
scopes: string[];
|
|
85
173
|
};
|
|
174
|
+
/**
|
|
175
|
+
* The verdict on an `asyncScan` upload, once per upload the host accepted.
|
|
176
|
+
* It arrives long after that upload's own reply, which is why it is a push.
|
|
177
|
+
*
|
|
178
|
+
* `requestId` echoes the `OPEN_IMAGE_UPLOAD` it belongs to, but the transport
|
|
179
|
+
* owns request ids and hands none out, so this client correlates on
|
|
180
|
+
* `imageId` — the id it was given in the reply.
|
|
181
|
+
*/
|
|
182
|
+
IMAGE_SCAN_RESOLVED: {
|
|
183
|
+
requestId: string;
|
|
184
|
+
imageId: number;
|
|
185
|
+
result: ImageScanResult;
|
|
186
|
+
};
|
|
86
187
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -9,10 +9,11 @@ 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 { StorageCallOptions, StorageClient, StorageKeyEntry, StorageListQuery, StorageListResult, StorageQuota, } from './storage/index.js';
|
|
12
13
|
export { isTerminal } from './orchestration/index.js';
|
|
13
14
|
export type { OrchestrationClient, Step, StepTemplate, WaitOptions, Workflow, WorkflowPage, WorkflowQuery, WorkflowStatus, WorkflowTemplate, } from './orchestration/index.js';
|
|
14
15
|
export { createHost } from './host/index.js';
|
|
15
|
-
export type { ConsentRefusal, DownloadRequest, Host, HostCallOptions, PickedResource, ResourcePickerType, } from './host/index.js';
|
|
16
|
+
export type { ConsentRefusal, DownloadRequest, Host, HostCallOptions, ImageScanResult, PendingImage, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './host/index.js';
|
|
16
17
|
export { ApiError } from './http/index.js';
|
|
17
18
|
export type { Query, QueryValue, RequestOptions } from './http/index.js';
|
|
18
19
|
export { BridgeError, CivitaiError } from './core/errors.js';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Http } from '../http/index.js';
|
|
2
|
+
export interface StorageCallOptions {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
}
|
|
5
|
+
/** One row of a key listing. Values are not returned — `get(key)` fetches them. */
|
|
6
|
+
export interface StorageKeyEntry {
|
|
7
|
+
key: string;
|
|
8
|
+
/**
|
|
9
|
+
* When this key was last written. The wire carries an ISO string; it is
|
|
10
|
+
* revived here, once, so a caller can do date arithmetic without knowing the
|
|
11
|
+
* transport.
|
|
12
|
+
*/
|
|
13
|
+
updatedAt: Date;
|
|
14
|
+
}
|
|
15
|
+
export interface StorageListResult {
|
|
16
|
+
keys: StorageKeyEntry[];
|
|
17
|
+
/**
|
|
18
|
+
* 🔴 PRESENT EXACTLY WHEN THERE MAY BE MORE ROWS, and passed through
|
|
19
|
+
* untouched. The server sets it iff the page it returned was full. A caller
|
|
20
|
+
* uses its ABSENCE as proof a scan completed, and for one caller that is a
|
|
21
|
+
* money decision. Never default it, never normalise it, never re-derive it
|
|
22
|
+
* from `keys.length`.
|
|
23
|
+
*/
|
|
24
|
+
nextCursor?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface StorageListQuery {
|
|
27
|
+
/** Narrows to keys starting with this. Escaped server-side against LIKE wildcards. */
|
|
28
|
+
prefix?: string;
|
|
29
|
+
/** The server bounds this and applies its own default; this client sends none. */
|
|
30
|
+
limit?: number;
|
|
31
|
+
/** An opaque `nextCursor` from a previous page. */
|
|
32
|
+
cursor?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface StorageQuota {
|
|
35
|
+
/**
|
|
36
|
+
* 🔴 THE STORED UNIT — Postgres `octet_length(value::text)` over JSONB, the
|
|
37
|
+
* unit both ceilings are enforced in, and the authority for "how close am I
|
|
38
|
+
* to my cap". It is NOT {@link StorageClient.set}'s `sizeBytes`: measured, a
|
|
39
|
+
* numeric-heavy payload stores up to 44.4x its wire size.
|
|
40
|
+
*/
|
|
41
|
+
usedBytes: number;
|
|
42
|
+
rowCount: number;
|
|
43
|
+
/** Render this, never a compiled-in figure — the ceiling can move. */
|
|
44
|
+
limitBytes: number;
|
|
45
|
+
/** Usually the BINDING one: many small rows exhaust this long before bytes. */
|
|
46
|
+
limitRows: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The viewer's own per-(app, block instance) key/value store.
|
|
50
|
+
*
|
|
51
|
+
* 🔴 Requires the block token the host mints. An app that authenticated with an
|
|
52
|
+
* OAuth access token instead has no per-viewer app storage — every call is
|
|
53
|
+
* refused.
|
|
54
|
+
*
|
|
55
|
+
* 🔴 EVERY FAILURE REJECTS. There is no path here that resolves to mean "not
|
|
56
|
+
* written", and none that resolves to mean "could not read". A caller that must
|
|
57
|
+
* not act on a partial view branches on the rejection, never on an empty result.
|
|
58
|
+
*/
|
|
59
|
+
export interface StorageClient {
|
|
60
|
+
/** The value, or `null` when the key is unset. Rejects on any refusal. */
|
|
61
|
+
get<T = unknown>(key: string, opts?: StorageCallOptions): Promise<T | null>;
|
|
62
|
+
/**
|
|
63
|
+
* Upsert one key. Resolves only when the write landed.
|
|
64
|
+
*
|
|
65
|
+
* `sizeBytes` is the WIRE unit — the byte length of the serialised value — so
|
|
66
|
+
* it predicts the per-value cap and nothing else. 🔴 Do not sum it to track
|
|
67
|
+
* quota; call {@link StorageClient.getQuota}. See {@link StorageQuota.usedBytes}.
|
|
68
|
+
*
|
|
69
|
+
* A value of `undefined` is stored as `null`: it does not survive JSON, and
|
|
70
|
+
* the server writes `null` for an absent value rather than refusing.
|
|
71
|
+
*/
|
|
72
|
+
set<T = unknown>(key: string, value: T, opts?: StorageCallOptions): Promise<{
|
|
73
|
+
ok: true;
|
|
74
|
+
sizeBytes: number;
|
|
75
|
+
}>;
|
|
76
|
+
/** Idempotent. `deleted: false` means the key was already absent — a success. */
|
|
77
|
+
delete(key: string, opts?: StorageCallOptions): Promise<{
|
|
78
|
+
ok: true;
|
|
79
|
+
deleted: boolean;
|
|
80
|
+
}>;
|
|
81
|
+
/** One page of keys, ordered by key ascending. Values are not returned. */
|
|
82
|
+
list(query?: StorageListQuery, opts?: StorageCallOptions): Promise<StorageListResult>;
|
|
83
|
+
/**
|
|
84
|
+
* This viewer's usage against this viewer's caps, in the stored unit.
|
|
85
|
+
*
|
|
86
|
+
* It reports neither the key-length cap nor the per-value cap, so a write
|
|
87
|
+
* that fits the numbers here can still be refused.
|
|
88
|
+
*/
|
|
89
|
+
getQuota(opts?: StorageCallOptions): Promise<StorageQuota>;
|
|
90
|
+
}
|
|
91
|
+
export declare function createStorageClient(http: Http): StorageClient;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { CivitaiError } from '../core/errors.js';
|
|
2
|
+
/** Where the five routes live under the site base URL. */
|
|
3
|
+
const BASE = 'blocks/app-storage';
|
|
4
|
+
export function createStorageClient(http) {
|
|
5
|
+
const post = (op, body, signal) => http('POST', `${BASE}/${op}`, { body, signal });
|
|
6
|
+
return {
|
|
7
|
+
async get(key, { signal } = {}) {
|
|
8
|
+
const res = await post('get', { key }, signal);
|
|
9
|
+
// 🔴 NO `res?.value ?? null`. `null` is this method's word for "the key is
|
|
10
|
+
// unset" — a real answer a caller acts on, including one that decides
|
|
11
|
+
// whether to spend. A reply that never carried `value` is not that answer.
|
|
12
|
+
if (res == null || typeof res !== 'object' || !('value' in res)) {
|
|
13
|
+
throw new CivitaiError('app-storage get: reply carried no `value`');
|
|
14
|
+
}
|
|
15
|
+
return (res.value ?? null);
|
|
16
|
+
},
|
|
17
|
+
async set(key, value, { signal } = {}) {
|
|
18
|
+
const res = await post('set', { key, value }, signal);
|
|
19
|
+
// Asserted, not cast: the declared `number` is a promise this client makes
|
|
20
|
+
// to a caller that renders it, and a cast would make it a lie we cannot see.
|
|
21
|
+
if (typeof res?.sizeBytes !== 'number') {
|
|
22
|
+
throw new CivitaiError('app-storage set: reply carried no `sizeBytes`');
|
|
23
|
+
}
|
|
24
|
+
return { ok: true, sizeBytes: res.sizeBytes };
|
|
25
|
+
},
|
|
26
|
+
async delete(key, { signal } = {}) {
|
|
27
|
+
const res = await post('delete', { key }, signal);
|
|
28
|
+
if (typeof res?.deleted !== 'boolean') {
|
|
29
|
+
throw new CivitaiError('app-storage delete: reply carried no `deleted` flag');
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, deleted: res.deleted };
|
|
32
|
+
},
|
|
33
|
+
async list(query = {}, { signal } = {}) {
|
|
34
|
+
const res = await post('list',
|
|
35
|
+
// Named rather than spread: each of the three is a field a mutation can
|
|
36
|
+
// drop on its own, and `JSON.stringify` omits the ones left undefined,
|
|
37
|
+
// so the server's own default applies instead of a second copy here.
|
|
38
|
+
{ prefix: query.prefix, limit: query.limit, cursor: query.cursor }, signal);
|
|
39
|
+
// 🔴 NO `?? []`. A malformed 200 must throw, never answer "no keys": a
|
|
40
|
+
// caller stands its double-spend backstop down on a scan that completed,
|
|
41
|
+
// and a manufactured empty page is indistinguishable from one.
|
|
42
|
+
if (!Array.isArray(res?.keys)) {
|
|
43
|
+
throw new CivitaiError('app-storage list: reply carried no `keys` array');
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
keys: res.keys.map(toEntry),
|
|
47
|
+
// 🔴 Passed through. Not defaulted, not normalised, not re-derived.
|
|
48
|
+
nextCursor: res.nextCursor,
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
getQuota: ({ signal } = {}) => post('quota', {}, signal),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function toEntry(row) {
|
|
55
|
+
const r = (row ?? {});
|
|
56
|
+
const raw = r.updatedAt;
|
|
57
|
+
// `new Date(null)` is the epoch, not an Invalid Date, so the NaN check below
|
|
58
|
+
// cannot be the only gate — a null timestamp would sort and compare silently
|
|
59
|
+
// wrong forever.
|
|
60
|
+
if (typeof r.key !== 'string' || !(typeof raw === 'string' || raw instanceof Date)) {
|
|
61
|
+
throw new CivitaiError('app-storage list: malformed key entry');
|
|
62
|
+
}
|
|
63
|
+
const at = new Date(raw);
|
|
64
|
+
if (Number.isNaN(at.getTime())) {
|
|
65
|
+
throw new CivitaiError('app-storage list: malformed key entry');
|
|
66
|
+
}
|
|
67
|
+
return { key: r.key, updatedAt: at };
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civitai/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -22,19 +22,6 @@
|
|
|
22
22
|
"README.md",
|
|
23
23
|
"dist"
|
|
24
24
|
],
|
|
25
|
-
"scripts": {
|
|
26
|
-
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
27
|
-
"api": "node scripts/api-report.mjs",
|
|
28
|
-
"api:check": "node scripts/api-report.mjs --check",
|
|
29
|
-
"check:layering": "node scripts/check-layering.mjs",
|
|
30
|
-
"check:parity": "node scripts/check-host-parity.mjs",
|
|
31
|
-
"gen:steps": "node scripts/gen-step-types.mjs",
|
|
32
|
-
"check:steps": "node scripts/gen-step-types.mjs --check",
|
|
33
|
-
"snapshot:host": "node scripts/snapshot-host-messages.mjs",
|
|
34
|
-
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json && tsc -p tsconfig.examples.json",
|
|
35
|
-
"test": "vitest run",
|
|
36
|
-
"test:watch": "vitest"
|
|
37
|
-
},
|
|
38
25
|
"engines": {
|
|
39
26
|
"node": ">=20"
|
|
40
27
|
},
|
|
@@ -64,5 +51,18 @@
|
|
|
64
51
|
},
|
|
65
52
|
"dependencies": {
|
|
66
53
|
"@civitai/orchestration-client": "0.2.0-beta.101"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
57
|
+
"api": "node scripts/api-report.mjs",
|
|
58
|
+
"api:check": "node scripts/api-report.mjs --check",
|
|
59
|
+
"check:layering": "node scripts/check-layering.mjs",
|
|
60
|
+
"check:parity": "node scripts/check-host-parity.mjs",
|
|
61
|
+
"gen:steps": "node scripts/gen-step-types.mjs",
|
|
62
|
+
"check:steps": "node scripts/gen-step-types.mjs --check",
|
|
63
|
+
"snapshot:host": "node scripts/snapshot-host-messages.mjs",
|
|
64
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json && tsc -p tsconfig.examples.json",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:watch": "vitest"
|
|
67
67
|
}
|
|
68
|
-
}
|
|
68
|
+
}
|