@civitai/sdk 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,14 @@ Both give you:
60
60
  A block's `app` also has `host`, `viewer`, `context`, `settings`, `theme` and
61
61
  `onChange(listener)`. Outside a block they do not exist, and the types say so.
62
62
 
63
+ `context` and `theme` are LIVE, not mount-time snapshots: the host pushes
64
+ `THEME_CHANGE` when the viewer toggles dark mode and `ROUTE_CHANGED` when a page
65
+ block's sub-path moves, and both are folded into the same snapshot — so
66
+ `app.context.subPath` tracks `app.host.navigate(path)` (and the viewer's own back
67
+ button) and `onChange` tells you when. Read them on each render rather than
68
+ copying them once; there is deliberately no `onRouteChange`, because one value
69
+ observed two ways is two values that can disagree.
70
+
63
71
  A block opts in by declaring `auth: "oauth"` in its `block.manifest.json`. The
64
72
  host then hands it a real OAuth access token that `/api/v1`, the orchestrator
65
73
  and the MCP accept, and consent — including `requestGrants` — goes through the
@@ -131,6 +139,29 @@ and a flag that the viewer signed in before (`returning`). Signing in again is
131
139
  a quick round trip, since Civitai remembers the consent. `requestGrants` goes
132
140
  back to Civitai for anything not yet granted.
133
141
 
142
+ ### Without leaving the page
143
+
144
+ A page that must not be navigated away from, such as one with a chat or an
145
+ editor embedded in it, signs in in a popup instead:
146
+
147
+ ```ts
148
+ import { createSignIn, initialize } from '@civitai/sdk';
149
+
150
+ const auth = await createSignIn({ clientId, scopes: ['ai:write:budgeted'] });
151
+ button.onclick = async () => {
152
+ await auth.signInWithPopup(); // from the click itself: browsers only open a popup there
153
+ const app = await initialize(auth);
154
+ };
155
+ ```
156
+
157
+ Civitai returns the popup to `redirectUri`, the same page by default. That page
158
+ calls `createSignIn()` as on any visit; in the popup it hands the result back to
159
+ the page that opened it and closes. The result travels over a same-origin
160
+ `BroadcastChannel`, not `window.opener`, because signing in through Google cuts
161
+ the opener. A blocked popup rejects with `SignInError` `code: 'popup-blocked'`;
162
+ pass a `signal` to give up, which rejects with `code: 'canceled'`.
163
+ `requestGrants` still leaves the page.
164
+
134
165
  Only scopes with an OAuth equivalent can be asked for: `user:read:self`,
135
166
  `models:read:self`, `ai:write:budgeted`, `buzz:read:self`, `posts:write:self`
136
167
  and `social:tip:self`. The storage and collection scopes belong to blocks.
@@ -315,7 +346,7 @@ wider scope — and refuses without one.
315
346
  | `resize(height)` | Resizes the frame, clamped to the manifest |
316
347
  | `autoResize(element?)` | Keeps the frame as tall as the body (or `element`); returns a stop function |
317
348
  | `reportError(message, { fatal })` | `fatal` swaps the block for the host's fallback |
318
- | `navigate(path, { target })` | Deep-links within this app's own sub-paths |
349
+ | `navigate(path, { scope, target })` | Deep-links within this app's own sub-paths; `scope: 'site'` asks to leave the app for a civitai.com page instead. `scope` defaults to `'app'` |
319
350
  | `onVisibilityChange(handler)` | Reports the page hiding and returning |
320
351
  | `requestSignIn({ returnUrl })` | Starts sign-in; the block re-initialises signed in |
321
352
  | `download({ url, filename })` | Saves to the viewer's device — a sandboxed frame cannot |
@@ -99,7 +99,17 @@ export interface BlockInitPayload {
99
99
  /** The domain ceiling narrowed to this viewer. Always a subset of the above. */
100
100
  effectiveBrowsingLevel?: number;
101
101
  }
102
- /** Host messages the transport itself acts on. Domains own everything else. */
102
+ /**
103
+ * Host messages the transport itself acts on. Domains own everything else.
104
+ *
105
+ * `ROUTE_CHANGED` is here rather than in `HostPushes` for the same reason
106
+ * `THEME_CHANGE` is: it carries a value the snapshot already holds, so the
107
+ * transport folds it in and `client.context` / `client.onChange` are how a
108
+ * consumer observes it. A `HostPushes` row plus an `onRouteChange` method would
109
+ * publish a SECOND way to read one value, which is how `client.context.subPath`
110
+ * and a callback end up disagreeing. `SUSPEND`/`RESUME` are in `HostPushes`
111
+ * because they carry no state for the snapshot to hold.
112
+ */
103
113
  export type HostMessage = {
104
114
  type: 'BLOCK_INIT';
105
115
  payload: BlockInitPayload;
@@ -119,6 +129,11 @@ export type HostMessage = {
119
129
  payload: {
120
130
  theme: Theme;
121
131
  };
132
+ } | {
133
+ type: 'ROUTE_CHANGED';
134
+ payload: {
135
+ subPath: string;
136
+ };
122
137
  };
123
138
  /** Discriminator only — the payload is still unvalidated wire data. */
124
139
  export declare function isHostMessage<K extends HostMessage['type']>(data: unknown, type: K): data is Extract<HostMessage, {
@@ -287,6 +287,18 @@ export class IframeTransport {
287
287
  this.#applyThemeChange(data.payload.theme);
288
288
  return;
289
289
  }
290
+ // The page's sub-path moved under the app root. Folded into the snapshot so
291
+ // `client.context.subPath` is live and `onChange` reports it — the same
292
+ // treatment as the theme, and the reason there is no `onRouteChange`.
293
+ //
294
+ // Unlike the theme this IS effectively init-gated, because the sub-path
295
+ // lives only in `context` and the pre-init context has no such key. That
296
+ // costs nothing: the host sends this only after init, and `BLOCK_INIT`
297
+ // replaces the snapshot with its own current value.
298
+ if (isHostMessage(data, 'ROUTE_CHANGED')) {
299
+ this.#applyRouteChange(data.payload.subPath);
300
+ return;
301
+ }
290
302
  // Matching the TYPE too, not just the id: `IMAGE_SCAN_RESOLVED` is a push
291
303
  // that deliberately reuses its `OPEN_IMAGE_UPLOAD`'s requestId, so an
292
304
  // id-only match could resolve the upload with the scan verdict's payload.
@@ -337,6 +349,18 @@ export class IframeTransport {
337
349
  this.#snapshot = next;
338
350
  this.#emit();
339
351
  }
352
+ // Only ever UPDATES a context that already carries the key; never INTRODUCES
353
+ // it. A model slot has no route, so synthesising `subPath` would assert a page
354
+ // context the host never sent. Emits only when the value moved.
355
+ #applyRouteChange(subPath) {
356
+ const context = this.#snapshot.context;
357
+ if (!('subPath' in context))
358
+ return;
359
+ if (context.subPath === subPath)
360
+ return;
361
+ this.#snapshot = { ...this.#snapshot, context: { ...context, subPath } };
362
+ this.#emit();
363
+ }
340
364
  #emit() {
341
365
  for (const listener of this.#listeners)
342
366
  listener();
@@ -1,7 +1,7 @@
1
1
  import { type BlockTransport } from '../core/transport.js';
2
2
  import type { Session } from '../session/index.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';
3
+ import type { DownloadRequest, ImageScanResult, NavigateScope, PickedResource, ResourcePickerType, SourceImage } from './protocol.js';
4
+ export type { ConsentRefusal, DownloadRequest, ImageScanResult, NavigateScope, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './protocol.js';
5
5
  export interface HostCallOptions {
6
6
  signal?: AbortSignal;
7
7
  }
@@ -33,8 +33,15 @@ export interface Host {
33
33
  reportError(message: string, args?: {
34
34
  fatal?: boolean;
35
35
  }): void;
36
- /** Deep-links within this app's own sub-paths; the host refuses anything else. */
36
+ /**
37
+ * Asks the host to navigate. `scope` picks the space `path` is resolved in and
38
+ * DEFAULTS to `'app'` — this app's own sub-paths, which is all this method
39
+ * could reach before the field existed. Pass `scope: 'site'` to leave the app
40
+ * for a civitai.com page; the host grants that per-surface and refuses it
41
+ * elsewhere. Fire-and-forget either way: a refusal is silent.
42
+ */
37
43
  navigate(path: string, args?: {
44
+ scope?: NavigateScope;
38
45
  target?: 'current' | 'new_tab';
39
46
  }): void;
40
47
  /** Fires `false` when the page hides and `true` when it returns. */
@@ -25,7 +25,12 @@ export function createHost(transport) {
25
25
  return () => observer.disconnect();
26
26
  },
27
27
  reportError: (message, args = {}) => notify('BLOCK_ERROR', { message, fatal: args.fatal ?? false }, { transport }),
28
- navigate: (path, args = {}) => notify('NAVIGATE', { path, target: args.target ?? 'current' }, { transport }),
28
+ // `scope` is OMITTED rather than sent as `undefined` when the caller did not
29
+ // choose one. Absent and `'app'` mean the same thing to the host, so this is
30
+ // not a behaviour difference — it keeps the wire payload byte-identical to
31
+ // what every pre-`scope` build sends, which is the property the back-compat
32
+ // claim rests on and the one a host-side diff can actually check.
33
+ navigate: (path, args = {}) => notify('NAVIGATE', { path, ...(args.scope ? { scope: args.scope } : {}), target: args.target ?? 'current' }, { transport }),
29
34
  onVisibilityChange(handler) {
30
35
  const offSuspend = on(transport, 'SUSPEND', () => handler(false));
31
36
  const offResume = on(transport, 'RESUME', () => handler(true));
@@ -1,5 +1,18 @@
1
1
  import type { WrappedToken } from '../core/handshake.js';
2
2
  export type ResourcePickerType = 'Checkpoint' | 'LORA';
3
+ /**
4
+ * Which SPACE a `navigate()` path is resolved in.
5
+ *
6
+ * - `'app'` — under this app's own route, as a sub-path of it. The DEFAULT.
7
+ * - `'site'` — at the site root, leaving the app. The host grants this
8
+ * per-surface and refuses it where the surface does not hold the capability.
9
+ *
10
+ * Omitting it means `'app'`, which is what keeps this field additive: a block
11
+ * that never sets it behaves as it did before the field existed. A leading slash
12
+ * carries no meaning — the host normalises it away in both scopes, so
13
+ * `'/settings'` and `'settings'` are one request within the scope you chose.
14
+ */
15
+ export type NavigateScope = 'app' | 'site';
3
16
  /** The one resource the viewer picked. Discovery only: nothing here is an entitlement. */
4
17
  export interface PickedResource {
5
18
  versionId: number;
@@ -153,6 +166,7 @@ export type HostNotifications = {
153
166
  };
154
167
  NAVIGATE: {
155
168
  path: string;
169
+ scope?: NavigateScope;
156
170
  target: 'current' | 'new_tab';
157
171
  };
158
172
  REQUEST_SIGN_IN: {
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export type { StorageCallOptions, StorageClient, StorageKeyEntry, StorageListQue
14
14
  export { isTerminal, isTerminalStatus } from './orchestration/index.js';
15
15
  export type { OrchestrationClient, Step, StepTemplate, WaitOptions, Workflow, WorkflowPage, WorkflowQuery, WorkflowStatus, WorkflowTemplate, } from './orchestration/index.js';
16
16
  export { createHost } from './host/index.js';
17
- export type { ConsentRefusal, DownloadRequest, Host, HostCallOptions, ImageScanResult, PendingImage, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './host/index.js';
17
+ export type { ConsentRefusal, DownloadRequest, Host, HostCallOptions, ImageScanResult, NavigateScope, PendingImage, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './host/index.js';
18
18
  export { ApiError } from './http/index.js';
19
19
  export type { Query, QueryValue, RequestOptions } from './http/index.js';
20
20
  export { BridgeError, CivitaiError } from './core/errors.js';
@@ -3,7 +3,11 @@ import type { GrantOptions, Scope, TokenSessionOptions } from '../session/index.
3
3
  export declare const DEFAULT_AUTH_URL = "https://auth.civitai.com";
4
4
  export declare class SignInError extends CivitaiError {
5
5
  readonly status?: number;
6
- constructor(message: string, status?: number, options?: ErrorOptions);
6
+ /** `canceled`: the viewer gave up on a popup sign-in. `popup-blocked`: the browser refused to open it. */
7
+ readonly code?: 'canceled' | 'popup-blocked';
8
+ constructor(message: string, status?: number, options?: ErrorOptions & {
9
+ code?: 'canceled' | 'popup-blocked';
10
+ });
7
11
  }
8
12
  type SignInStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
9
13
  export interface SignInOptions {
@@ -34,6 +38,15 @@ export interface SignIn extends TokenSessionOptions {
34
38
  signIn(opts?: {
35
39
  scopes?: readonly Scope[];
36
40
  }): Promise<never>;
41
+ /**
42
+ * Signs in in a popup, so the page stays where it is. Call it straight from a click or key press:
43
+ * browsers only open a popup there. Civitai returns the popup to `redirectUri`, whose page must call
44
+ * `createSignIn()` with the same client, which hands the result back here and closes the popup.
45
+ */
46
+ signInWithPopup(opts?: {
47
+ scopes?: readonly Scope[];
48
+ signal?: AbortSignal;
49
+ }): Promise<void>;
37
50
  signOut(): Promise<void>;
38
51
  }
39
52
  export declare function scopeBitmask(scopes: readonly Scope[]): number;
@@ -13,10 +13,13 @@ const OAUTH_BITS = {
13
13
  const EXPIRY_MARGIN_MS = 60_000;
14
14
  export class SignInError extends CivitaiError {
15
15
  status;
16
+ /** `canceled`: the viewer gave up on a popup sign-in. `popup-blocked`: the browser refused to open it. */
17
+ code;
16
18
  constructor(message, status, options) {
17
19
  super(message, options);
18
20
  this.name = 'SignInError';
19
21
  this.status = status;
22
+ this.code = options?.code;
20
23
  }
21
24
  }
22
25
  export function scopeBitmask(scopes) {
@@ -62,6 +65,14 @@ function writeJson(storage, key, value) {
62
65
  // Private windows can refuse storage; the viewer then signs in again next visit.
63
66
  }
64
67
  }
68
+ const canceled = () => new SignInError('sign-in was canceled', undefined, { code: 'canceled' });
69
+ function popupFeatures(win) {
70
+ const width = 520;
71
+ const height = 720;
72
+ const left = Math.max(0, Math.round(win.screenX + (win.outerWidth - width) / 2));
73
+ const top = Math.max(0, Math.round(win.screenY + (win.outerHeight - height) / 2));
74
+ return `popup,width=${width},height=${height},left=${left},top=${top}`;
75
+ }
65
76
  /**
66
77
  * Sign-in with Civitai for an app outside civitai.com. Completes the return
67
78
  * from Civitai when the page has just come back from it.
@@ -72,6 +83,9 @@ export async function createSignIn(options) {
72
83
  const doFetch = options.fetch ?? fetch.bind(globalThis);
73
84
  const authUrl = options.authUrl ?? DEFAULT_AUTH_URL;
74
85
  const key = `civitai.sign-in.${options.clientId}`;
86
+ // A same-origin channel, not `popup.opener`: Civitai may pass the popup through Google, whose
87
+ // opener policy cuts that link for good.
88
+ const channelName = key;
75
89
  const pendingKey = `${key}.pending`;
76
90
  const returningKey = `${key}.returning`;
77
91
  const requested = scopeBitmask(options.scopes);
@@ -123,15 +137,14 @@ export async function createSignIn(options) {
123
137
  });
124
138
  return refreshing;
125
139
  };
126
- const signIn = async ({ scopes } = {}) => {
127
- const verifier = randomString();
128
- const pending = {
129
- state: randomString(),
130
- verifier,
131
- redirectUri: options.redirectUri ?? `${win.location.origin}${win.location.pathname}`,
132
- scope: requested | (scopes ? scopeBitmask(scopes) : 0) | (tokens?.scope ?? 0),
133
- };
134
- writeJson(storage, pendingKey, pending);
140
+ const newPending = (scopes, popup) => ({
141
+ state: randomString(),
142
+ verifier: randomString(),
143
+ redirectUri: options.redirectUri ?? `${win.location.origin}${win.location.pathname}`,
144
+ scope: requested | (scopes ? scopeBitmask(scopes) : 0) | (tokens?.scope ?? 0),
145
+ ...(popup ? { popup: true } : {}),
146
+ });
147
+ const authorizeUrl = async (pending) => {
135
148
  const url = new URL('/api/auth/oauth/authorize', authUrl);
136
149
  url.search = new URLSearchParams({
137
150
  client_id: options.clientId,
@@ -139,33 +152,79 @@ export async function createSignIn(options) {
139
152
  response_type: 'code',
140
153
  state: pending.state,
141
154
  scope: String(pending.scope),
142
- code_challenge: await challengeFor(verifier),
155
+ code_challenge: await challengeFor(pending.verifier),
143
156
  code_challenge_method: 'S256',
144
157
  }).toString();
145
- win.location.assign(url.toString());
158
+ return url.toString();
159
+ };
160
+ const complete = async (pending, params) => {
161
+ const code = params.get('code');
162
+ if (!code)
163
+ throw new SignInError(params.get('error_description') ?? params.get('error') ?? 'sign-in was declined');
164
+ save(await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: pending.redirectUri, code_verifier: pending.verifier }, pending.scope));
165
+ };
166
+ const signIn = async ({ scopes } = {}) => {
167
+ const pending = newPending(scopes, false);
168
+ writeJson(storage, pendingKey, pending);
169
+ win.location.assign(await authorizeUrl(pending));
146
170
  return new Promise(() => { });
147
171
  };
172
+ const signInWithPopup = async ({ scopes, signal } = {}) => {
173
+ // Written before the popup opens: a popup starts with a copy of this page's sessionStorage.
174
+ const pending = newPending(scopes, true);
175
+ writeJson(storage, pendingKey, pending);
176
+ const popup = win.open('about:blank', key, popupFeatures(win));
177
+ if (!popup) {
178
+ writeJson(storage, pendingKey, null);
179
+ throw new SignInError('the browser blocked the sign-in popup', undefined, { code: 'popup-blocked' });
180
+ }
181
+ const channel = new BroadcastChannel(channelName);
182
+ try {
183
+ const returned = new Promise((resolve, reject) => {
184
+ channel.onmessage = (event) => {
185
+ const params = new URLSearchParams(typeof event.data?.search === 'string' ? event.data.search : '');
186
+ if (params.get('state') === pending.state)
187
+ resolve(params);
188
+ };
189
+ if (signal?.aborted)
190
+ reject(canceled());
191
+ signal?.addEventListener('abort', () => reject(canceled()), { once: true });
192
+ });
193
+ popup.location.replace(await authorizeUrl(pending));
194
+ await complete(pending, await returned);
195
+ error = null;
196
+ }
197
+ finally {
198
+ channel.close();
199
+ if (readJson(storage, pendingKey)?.state === pending.state)
200
+ writeJson(storage, pendingKey, null);
201
+ if (!popup.closed)
202
+ popup.close();
203
+ }
204
+ };
148
205
  const params = new URLSearchParams(win.location.search);
149
206
  const pending = readJson(storage, pendingKey);
150
207
  if (pending && params.get('state') === pending.state) {
151
- writeJson(storage, pendingKey, null);
152
- const code = params.get('code');
153
208
  const clean = new URL(win.location.href);
154
209
  for (const key of ['code', 'state', 'error', 'error_description'])
155
210
  clean.searchParams.delete(key);
156
- win.history.replaceState(win.history.state, '', clean.toString());
157
- try {
158
- if (!code)
159
- throw new SignInError(params.get('error_description') ?? params.get('error') ?? 'sign-in was declined');
160
- save(await tokenRequest({
161
- grant_type: 'authorization_code',
162
- code,
163
- redirect_uri: pending.redirectUri,
164
- code_verifier: pending.verifier,
165
- }, pending.scope));
211
+ if (pending.popup) {
212
+ // This is the popup: the page that opened it holds the verifier and does the exchange.
213
+ const channel = new BroadcastChannel(channelName);
214
+ channel.postMessage({ search: win.location.search });
215
+ channel.close();
216
+ win.history.replaceState(win.history.state, '', clean.toString());
217
+ win.close();
166
218
  }
167
- catch (err) {
168
- error = err instanceof SignInError ? err : new SignInError('sign-in failed', undefined, { cause: err });
219
+ else {
220
+ writeJson(storage, pendingKey, null);
221
+ win.history.replaceState(win.history.state, '', clean.toString());
222
+ try {
223
+ await complete(pending, params);
224
+ }
225
+ catch (err) {
226
+ error = err instanceof SignInError ? err : new SignInError('sign-in failed', undefined, { cause: err });
227
+ }
169
228
  }
170
229
  }
171
230
  return {
@@ -194,6 +253,7 @@ export async function createSignIn(options) {
194
253
  return signIn({ scopes });
195
254
  },
196
255
  signIn,
256
+ signInWithPopup,
197
257
  signOut: async () => {
198
258
  const held = tokens;
199
259
  save(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",