@civitai/sdk 0.8.0 → 0.9.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
@@ -131,6 +131,29 @@ and a flag that the viewer signed in before (`returning`). Signing in again is
131
131
  a quick round trip, since Civitai remembers the consent. `requestGrants` goes
132
132
  back to Civitai for anything not yet granted.
133
133
 
134
+ ### Without leaving the page
135
+
136
+ A page that must not be navigated away from, such as one with a chat or an
137
+ editor embedded in it, signs in in a popup instead:
138
+
139
+ ```ts
140
+ import { createSignIn, initialize } from '@civitai/sdk';
141
+
142
+ const auth = await createSignIn({ clientId, scopes: ['ai:write:budgeted'] });
143
+ button.onclick = async () => {
144
+ await auth.signInWithPopup(); // from the click itself: browsers only open a popup there
145
+ const app = await initialize(auth);
146
+ };
147
+ ```
148
+
149
+ Civitai returns the popup to `redirectUri`, the same page by default. That page
150
+ calls `createSignIn()` as on any visit; in the popup it hands the result back to
151
+ the page that opened it and closes. The result travels over a same-origin
152
+ `BroadcastChannel`, not `window.opener`, because signing in through Google cuts
153
+ the opener. A blocked popup rejects with `SignInError` `code: 'popup-blocked'`;
154
+ pass a `signal` to give up, which rejects with `code: 'canceled'`.
155
+ `requestGrants` still leaves the page.
156
+
134
157
  Only scopes with an OAuth equivalent can be asked for: `user:read:self`,
135
158
  `models:read:self`, `ai:write:budgeted`, `buzz:read:self`, `posts:write:self`
136
159
  and `social:tip:self`. The storage and collection scopes belong to blocks.
@@ -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.9.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",