@civitai/sdk 0.2.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.
Files changed (39) hide show
  1. package/BREAKING.md +69 -0
  2. package/README.md +186 -0
  3. package/dist/app/index.d.ts +50 -0
  4. package/dist/app/index.js +64 -0
  5. package/dist/core/errors.d.ts +19 -0
  6. package/dist/core/errors.js +42 -0
  7. package/dist/core/get-transport.d.ts +11 -0
  8. package/dist/core/get-transport.js +101 -0
  9. package/dist/core/handshake.d.ts +135 -0
  10. package/dist/core/handshake.js +60 -0
  11. package/dist/core/messaging.d.ts +30 -0
  12. package/dist/core/messaging.js +23 -0
  13. package/dist/core/origin-matcher.d.ts +12 -0
  14. package/dist/core/origin-matcher.js +71 -0
  15. package/dist/core/transport.d.ts +60 -0
  16. package/dist/core/transport.js +37 -0
  17. package/dist/core/transports/iframe-transport.d.ts +27 -0
  18. package/dist/core/transports/iframe-transport.js +342 -0
  19. package/dist/host/index.d.ts +47 -0
  20. package/dist/host/index.js +92 -0
  21. package/dist/host/protocol.d.ts +86 -0
  22. package/dist/host/protocol.js +1 -0
  23. package/dist/http/index.d.ts +23 -0
  24. package/dist/http/index.js +76 -0
  25. package/dist/index.d.ts +23 -0
  26. package/dist/index.js +12 -0
  27. package/dist/orchestration/index.d.ts +53 -0
  28. package/dist/orchestration/index.js +81 -0
  29. package/dist/orchestration/steps.generated.d.ts +5 -0
  30. package/dist/orchestration/steps.generated.js +2 -0
  31. package/dist/session/index.d.ts +29 -0
  32. package/dist/session/index.js +23 -0
  33. package/dist/sign-in/index.d.ts +45 -0
  34. package/dist/sign-in/index.js +209 -0
  35. package/dist/site/index.d.ts +12 -0
  36. package/dist/site/index.js +8 -0
  37. package/dist/testing.d.ts +36 -0
  38. package/dist/testing.js +108 -0
  39. package/package.json +68 -0
@@ -0,0 +1,209 @@
1
+ import { CivitaiError } from '../core/errors.js';
2
+ export const DEFAULT_AUTH_URL = 'https://auth.civitai.com';
3
+ // The site's own table (BLOCK_SCOPE_TO_OAUTH_BIT). Storage and collection scopes have no OAuth bit.
4
+ const OAUTH_BITS = {
5
+ 'user:read:self': 1 << 0,
6
+ 'posts:write:self': 1 << 6,
7
+ 'ai:write:budgeted': 1 << 15,
8
+ 'buzz:read:self': 1 << 16,
9
+ 'social:tip:self': 1 << 20,
10
+ 'models:read:self': 1 << 2,
11
+ };
12
+ /** Refreshed this long before it expires, so a request never carries a token that dies in flight. */
13
+ const EXPIRY_MARGIN_MS = 60_000;
14
+ export class SignInError extends CivitaiError {
15
+ status;
16
+ constructor(message, status, options) {
17
+ super(message, options);
18
+ this.name = 'SignInError';
19
+ this.status = status;
20
+ }
21
+ }
22
+ export function scopeBitmask(scopes) {
23
+ let mask = 0;
24
+ for (const scope of scopes) {
25
+ const bit = OAUTH_BITS[scope];
26
+ if (bit === undefined)
27
+ throw new SignInError(`${scope} is only granted to apps running inside civitai.com`);
28
+ mask |= bit;
29
+ }
30
+ return mask;
31
+ }
32
+ function scopesIn(mask) {
33
+ return Object.keys(OAUTH_BITS).filter((scope) => (mask & OAUTH_BITS[scope]) !== 0);
34
+ }
35
+ function base64Url(bytes) {
36
+ return btoa(String.fromCharCode(...bytes))
37
+ .replace(/\+/g, '-')
38
+ .replace(/\//g, '_')
39
+ .replace(/=+$/, '');
40
+ }
41
+ const randomString = () => base64Url(crypto.getRandomValues(new Uint8Array(32)));
42
+ async function challengeFor(verifier) {
43
+ return base64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
44
+ }
45
+ function readJson(storage, key) {
46
+ try {
47
+ const raw = storage.getItem(key);
48
+ return raw ? JSON.parse(raw) : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ function writeJson(storage, key, value) {
55
+ try {
56
+ if (value === null)
57
+ storage.removeItem(key);
58
+ else
59
+ storage.setItem(key, JSON.stringify(value));
60
+ }
61
+ catch {
62
+ // Private windows can refuse storage; the viewer then signs in again next visit.
63
+ }
64
+ }
65
+ /**
66
+ * Sign-in with Civitai for an app outside civitai.com. Completes the return
67
+ * from Civitai when the page has just come back from it.
68
+ */
69
+ export async function createSignIn(options) {
70
+ const win = options.window ?? window;
71
+ const storage = options.storage ?? win.localStorage;
72
+ const doFetch = options.fetch ?? fetch.bind(globalThis);
73
+ const authUrl = options.authUrl ?? DEFAULT_AUTH_URL;
74
+ const key = `civitai.sign-in.${options.clientId}`;
75
+ const pendingKey = `${key}.pending`;
76
+ const returningKey = `${key}.returning`;
77
+ const requested = scopeBitmask(options.scopes);
78
+ let tokens = null;
79
+ let error = null;
80
+ let refreshing = null;
81
+ const save = (next) => {
82
+ tokens = next;
83
+ writeJson(storage, returningKey, next ? true : null);
84
+ };
85
+ const tokenRequest = async (body, fallbackScope) => {
86
+ const res = await doFetch(`${authUrl}/api/auth/oauth/token`, {
87
+ method: 'POST',
88
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
89
+ body: new URLSearchParams({ ...body, client_id: options.clientId }),
90
+ });
91
+ const json = (await res.json().catch(() => ({})));
92
+ if (!res.ok || !json.access_token) {
93
+ throw new SignInError(json.error_description ?? json.error ?? `token request failed: ${res.status}`, res.status);
94
+ }
95
+ const scope = Number(json.scope);
96
+ return {
97
+ accessToken: json.access_token,
98
+ refreshToken: json.refresh_token ?? body.refresh_token,
99
+ expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000,
100
+ // The hub may omit scope when it granted exactly what was asked (RFC 6749 §5.1).
101
+ scope: Number.isSafeInteger(scope) && scope >= 0 ? scope : fallbackScope,
102
+ };
103
+ };
104
+ const refresh = () => {
105
+ refreshing ??= (async () => {
106
+ const held = tokens;
107
+ if (!held?.refreshToken) {
108
+ save(null);
109
+ throw new SignInError('not signed in', 401);
110
+ }
111
+ try {
112
+ const next = await tokenRequest({ grant_type: 'refresh_token', refresh_token: held.refreshToken }, held.scope);
113
+ save(next);
114
+ return next.accessToken;
115
+ }
116
+ catch (err) {
117
+ if (err instanceof SignInError && err.status !== undefined && err.status < 500)
118
+ save(null);
119
+ throw err;
120
+ }
121
+ })().finally(() => {
122
+ refreshing = null;
123
+ });
124
+ return refreshing;
125
+ };
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);
135
+ const url = new URL('/api/auth/oauth/authorize', authUrl);
136
+ url.search = new URLSearchParams({
137
+ client_id: options.clientId,
138
+ redirect_uri: pending.redirectUri,
139
+ response_type: 'code',
140
+ state: pending.state,
141
+ scope: String(pending.scope),
142
+ code_challenge: await challengeFor(verifier),
143
+ code_challenge_method: 'S256',
144
+ }).toString();
145
+ win.location.assign(url.toString());
146
+ return new Promise(() => { });
147
+ };
148
+ const params = new URLSearchParams(win.location.search);
149
+ const pending = readJson(storage, pendingKey);
150
+ if (pending && params.get('state') === pending.state) {
151
+ writeJson(storage, pendingKey, null);
152
+ const code = params.get('code');
153
+ const clean = new URL(win.location.href);
154
+ for (const key of ['code', 'state', 'error', 'error_description'])
155
+ 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));
166
+ }
167
+ catch (err) {
168
+ error = err instanceof SignInError ? err : new SignInError('sign-in failed', undefined, { cause: err });
169
+ }
170
+ }
171
+ return {
172
+ get signedIn() {
173
+ return tokens !== null;
174
+ },
175
+ get returning() {
176
+ return tokens === null && readJson(storage, returningKey) === true;
177
+ },
178
+ get grantedScopes() {
179
+ return tokens ? scopesIn(tokens.scope) : [];
180
+ },
181
+ get error() {
182
+ return error;
183
+ },
184
+ token: async () => {
185
+ if (!tokens)
186
+ throw new SignInError('not signed in', 401);
187
+ return tokens.expiresAt - EXPIRY_MARGIN_MS > Date.now() ? tokens.accessToken : refresh();
188
+ },
189
+ refresh,
190
+ requestGrants: async (scopes) => {
191
+ const wanted = scopeBitmask(scopes);
192
+ if (tokens && (tokens.scope & wanted) === wanted)
193
+ return true;
194
+ return signIn({ scopes });
195
+ },
196
+ signIn,
197
+ signOut: async () => {
198
+ const held = tokens;
199
+ save(null);
200
+ if (!held)
201
+ return;
202
+ await doFetch(`${authUrl}/api/auth/oauth/revoke`, {
203
+ method: 'POST',
204
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
205
+ body: new URLSearchParams({ token: held.refreshToken ?? held.accessToken, client_id: options.clientId }),
206
+ }).catch(() => undefined);
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,12 @@
1
+ import type { Http, RequestOptions } from '../http/index.js';
2
+ export declare const DEFAULT_SITE_URL = "https://civitai.com/api/v1";
3
+ /**
4
+ * The public Civitai REST API. Routes are addressed by path, so a route the
5
+ * API gains needs no release here.
6
+ */
7
+ export interface SiteClient {
8
+ get<T = unknown>(path: string, opts?: Omit<RequestOptions, 'body'>): Promise<T>;
9
+ post<T = unknown>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'body'>): Promise<T>;
10
+ request<T = unknown>(method: string, path: string, opts?: RequestOptions): Promise<T>;
11
+ }
12
+ export declare function createSiteClient(http: Http): SiteClient;
@@ -0,0 +1,8 @@
1
+ export const DEFAULT_SITE_URL = 'https://civitai.com/api/v1';
2
+ export function createSiteClient(http) {
3
+ return {
4
+ get: (path, opts) => http('GET', path, opts),
5
+ post: (path, body, opts) => http('POST', path, { ...opts, body }),
6
+ request: (method, path, opts) => http(method, path, opts),
7
+ };
8
+ }
@@ -0,0 +1,36 @@
1
+ import { type BlockSnapshot, type BlockTransport } from './core/transport.js';
2
+ import { type BridgeFailureCode } from './core/errors.js';
3
+ export { __resetTransport } from './core/get-transport.js';
4
+ export interface FakeTransport extends BlockTransport {
5
+ /** Every request sent, in order. */
6
+ readonly sent: {
7
+ type: string;
8
+ payload: unknown;
9
+ }[];
10
+ /**
11
+ * Answer every request of this type from its params. Throw from the handler
12
+ * to fail the call. A queued `reply`/`fail` is used first, so one call can
13
+ * depart from the standing answer.
14
+ */
15
+ handle(type: string, handler: (params: unknown) => unknown): void;
16
+ /** Answer the next request of this type with the value it resolves to. */
17
+ reply(type: string, result: unknown): void;
18
+ /** Answer the next request of this type with a failure the host classified. */
19
+ fail(type: string, error: {
20
+ code: BridgeFailureCode;
21
+ message: string;
22
+ }): void;
23
+ /** Never answer requests of this type; for deadline and abort tests. */
24
+ stall(type: string): void;
25
+ /** Deliver an unsolicited host push to `on` subscribers. */
26
+ push(type: string, payload: unknown): void;
27
+ /** Subscribers to a push, so a test can assert one stopped listening. */
28
+ listenerCount(type: string): number;
29
+ setSnapshot(next: Partial<BlockSnapshot>): void;
30
+ }
31
+ /**
32
+ * An in-memory `BlockTransport` for testing a service against a scripted host.
33
+ * Replaces driving a full mock host through `postMessage` when the thing under
34
+ * test is one operation.
35
+ */
36
+ export declare function createFakeTransport(snapshot?: Partial<BlockSnapshot>): FakeTransport;
@@ -0,0 +1,108 @@
1
+ import { EMPTY_SNAPSHOT } from './core/transport.js';
2
+ import { BridgeError } from './core/errors.js';
3
+ export { __resetTransport } from './core/get-transport.js';
4
+ /**
5
+ * An in-memory `BlockTransport` for testing a service against a scripted host.
6
+ * Replaces driving a full mock host through `postMessage` when the thing under
7
+ * test is one operation.
8
+ */
9
+ export function createFakeTransport(snapshot = {}) {
10
+ let current = {
11
+ ...EMPTY_SNAPSHOT,
12
+ ready: true,
13
+ hostOrigin: 'https://civitai.com',
14
+ ...snapshot,
15
+ };
16
+ const listeners = new Set();
17
+ const sent = [];
18
+ const queued = new Map();
19
+ const stalled = new Set();
20
+ const pending = new Map();
21
+ const handlers = new Map();
22
+ const pushListeners = new Map();
23
+ const emit = () => {
24
+ for (const l of [...listeners])
25
+ l();
26
+ };
27
+ /** A queued answer is either the value or the failure the host reported. */
28
+ const settle = (answer) => answer instanceof BridgeError ? Promise.reject(answer) : Promise.resolve(answer);
29
+ const answer = (type, outcome) => {
30
+ const waiting = pending.get(type);
31
+ if (waiting?.length) {
32
+ waiting.shift()(outcome);
33
+ return;
34
+ }
35
+ queued.set(type, [...(queued.get(type) ?? []), outcome]);
36
+ };
37
+ return {
38
+ sent,
39
+ snapshot: {
40
+ get: () => current,
41
+ subscribe(listener) {
42
+ listeners.add(listener);
43
+ return () => listeners.delete(listener);
44
+ },
45
+ },
46
+ notify(message) {
47
+ sent.push({ type: message.type, payload: message.payload });
48
+ },
49
+ request(type, params, opts = {}) {
50
+ sent.push({ type, payload: params });
51
+ const responseType = type;
52
+ const { signal } = opts;
53
+ if (signal?.aborted)
54
+ return Promise.reject(signal.reason);
55
+ if (stalled.has(responseType)) {
56
+ return new Promise((_resolve, reject) => {
57
+ signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
58
+ });
59
+ }
60
+ const ready = queued.get(responseType);
61
+ if (ready?.length)
62
+ return settle(ready.shift());
63
+ const standing = handlers.get(responseType);
64
+ if (standing)
65
+ return (async () => standing(params))();
66
+ return new Promise((resolve, reject) => {
67
+ signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
68
+ const waiting = pending.get(responseType) ?? [];
69
+ waiting.push((outcome) => {
70
+ if (outcome instanceof BridgeError)
71
+ reject(outcome);
72
+ else
73
+ resolve(outcome);
74
+ });
75
+ pending.set(responseType, waiting);
76
+ });
77
+ },
78
+ on(type, handler) {
79
+ const set = pushListeners.get(type) ?? new Set();
80
+ set.add(handler);
81
+ pushListeners.set(type, set);
82
+ return () => set.delete(handler);
83
+ },
84
+ push(type, payload) {
85
+ for (const handler of [...(pushListeners.get(type) ?? [])])
86
+ handler(payload);
87
+ },
88
+ listenerCount(type) {
89
+ return pushListeners.get(type)?.size ?? 0;
90
+ },
91
+ handle(type, handler) {
92
+ handlers.set(type, handler);
93
+ },
94
+ reply(type, result) {
95
+ answer(type, result);
96
+ },
97
+ fail(type, error) {
98
+ answer(type, new BridgeError(error.code, type, error.message));
99
+ },
100
+ stall(type) {
101
+ stalled.add(type);
102
+ },
103
+ setSnapshot(next) {
104
+ current = { ...current, ...next };
105
+ emit();
106
+ },
107
+ };
108
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@civitai/sdk",
3
+ "version": "0.2.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "sideEffects": false,
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./testing": {
16
+ "types": "./dist/testing.d.ts",
17
+ "import": "./dist/testing.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "BREAKING.md",
22
+ "README.md",
23
+ "dist"
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
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^25.9.1",
43
+ "typescript": "^7.0.2",
44
+ "vite": "^8.0.14",
45
+ "vitest": "^4.1.11"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "keywords": [
51
+ "civitai",
52
+ "sdk",
53
+ "api",
54
+ "orchestration",
55
+ "iframe",
56
+ "postmessage"
57
+ ],
58
+ "homepage": "https://github.com/civitai/civitai-app-starters/tree/main/packages/civitai-sdk#readme",
59
+ "bugs": "https://github.com/civitai/civitai-app-starters/issues",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/civitai/civitai-app-starters.git",
63
+ "directory": "packages/civitai-sdk"
64
+ },
65
+ "dependencies": {
66
+ "@civitai/orchestration-client": "0.2.0-beta.101"
67
+ }
68
+ }