@raster-app/sdk 0.1.0-canary-2e1834e

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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/__generated__/openapi.d.ts +1096 -0
  4. package/dist/__generated__/openapi.js +2 -0
  5. package/dist/__generated__/openapi.js.map +1 -0
  6. package/dist/adapters/index.d.ts +31 -0
  7. package/dist/adapters/index.js +2 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/local-storage.d.ts +3 -0
  10. package/dist/adapters/local-storage.js +49 -0
  11. package/dist/adapters/local-storage.js.map +1 -0
  12. package/dist/adapters/memory.d.ts +7 -0
  13. package/dist/adapters/memory.js +19 -0
  14. package/dist/adapters/memory.js.map +1 -0
  15. package/dist/api/assets.d.ts +41 -0
  16. package/dist/api/assets.js +70 -0
  17. package/dist/api/assets.js.map +1 -0
  18. package/dist/api/errors.d.ts +22 -0
  19. package/dist/api/errors.js +80 -0
  20. package/dist/api/errors.js.map +1 -0
  21. package/dist/api/libraries.d.ts +16 -0
  22. package/dist/api/libraries.js +22 -0
  23. package/dist/api/libraries.js.map +1 -0
  24. package/dist/api/me.d.ts +40 -0
  25. package/dist/api/me.js +39 -0
  26. package/dist/api/me.js.map +1 -0
  27. package/dist/api/promote.d.ts +36 -0
  28. package/dist/api/promote.js +29 -0
  29. package/dist/api/promote.js.map +1 -0
  30. package/dist/api/search.d.ts +25 -0
  31. package/dist/api/search.js +21 -0
  32. package/dist/api/search.js.map +1 -0
  33. package/dist/api/transport.d.ts +43 -0
  34. package/dist/api/transport.js +90 -0
  35. package/dist/api/transport.js.map +1 -0
  36. package/dist/api/upload.d.ts +40 -0
  37. package/dist/api/upload.js +60 -0
  38. package/dist/api/upload.js.map +1 -0
  39. package/dist/auth/device.d.ts +32 -0
  40. package/dist/auth/device.js +70 -0
  41. package/dist/auth/device.js.map +1 -0
  42. package/dist/auth/http.d.ts +25 -0
  43. package/dist/auth/http.js +70 -0
  44. package/dist/auth/http.js.map +1 -0
  45. package/dist/auth/register.d.ts +14 -0
  46. package/dist/auth/register.js +21 -0
  47. package/dist/auth/register.js.map +1 -0
  48. package/dist/auth/session.d.ts +30 -0
  49. package/dist/auth/session.js +231 -0
  50. package/dist/auth/session.js.map +1 -0
  51. package/dist/auth/tokens.d.ts +10 -0
  52. package/dist/auth/tokens.js +20 -0
  53. package/dist/auth/tokens.js.map +1 -0
  54. package/dist/client.d.ts +138 -0
  55. package/dist/client.js +122 -0
  56. package/dist/client.js.map +1 -0
  57. package/dist/constants.d.ts +18 -0
  58. package/dist/constants.js +24 -0
  59. package/dist/constants.js.map +1 -0
  60. package/dist/format.d.ts +13 -0
  61. package/dist/format.js +83 -0
  62. package/dist/format.js.map +1 -0
  63. package/dist/images/blurhash-placeholder.d.ts +5 -0
  64. package/dist/images/blurhash-placeholder.js +47 -0
  65. package/dist/images/blurhash-placeholder.js.map +1 -0
  66. package/dist/images/blurhash.d.ts +2 -0
  67. package/dist/images/blurhash.js +77 -0
  68. package/dist/images/blurhash.js.map +1 -0
  69. package/dist/images/image-src.d.ts +42 -0
  70. package/dist/images/image-src.js +83 -0
  71. package/dist/images/image-src.js.map +1 -0
  72. package/dist/images/sizes.d.ts +15 -0
  73. package/dist/images/sizes.js +41 -0
  74. package/dist/images/sizes.js.map +1 -0
  75. package/dist/index.d.ts +23 -0
  76. package/dist/index.js +17 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/types/asset.d.ts +50 -0
  79. package/dist/types/asset.js +23 -0
  80. package/dist/types/asset.js.map +1 -0
  81. package/dist/types/library.d.ts +21 -0
  82. package/dist/types/library.js +2 -0
  83. package/dist/types/library.js.map +1 -0
  84. package/dist/types/session.d.ts +59 -0
  85. package/dist/types/session.js +21 -0
  86. package/dist/types/session.js.map +1 -0
  87. package/package.json +42 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/auth/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAe/D;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC7B,OAAoB,EACpB,IAAY,EACZ,IAA6B,EAC7B,UAAoD,EAAE;IAEtD,IAAI,QAAkB,CAAA;IACtB,IAAI,CAAC;QACJ,QAAQ,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,EAAE,EAAE;YAC9D,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACR,cAAc,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,mCAAmC;aACvF;YACD,IAAI,EAAE,OAAO,CAAC,IAAI;gBACjB,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;gBACtB,CAAC,CAAC,IAAI,eAAe,CACnB,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAChE,CAAC,QAAQ,EAAE;YACd,qFAAqF;YACrF,qFAAqF;YACrF,iDAAiD;YACjD,QAAQ,EAAE,OAAO;YACjB,MAAM,EAAE,OAAO,CAAC,MAAM;SACtB,CAAC,CAAA;IACH,CAAC;IAAC,OAAO,MAAM,EAAE,CAAC;QACjB,MAAM,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3B,CAAC;IAED,MAAM,OAAO,GAAM,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;IAE1D,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QAClB,MAAM,OAAO,GAAW,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAA;QACtF,MAAM,KAAK,GAAG,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAA;QAC5D,MAAM,WAAW,GAAG,mBAAmB,IAAI,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC,SAAS,CAAA;QAC1F,MAAM,IAAI,cAAc,CACvB,OAAO,WAAW,KAAK,QAAQ;YAC9B,CAAC,CAAC,WAAW;YACb,CAAC,CAAC,+BAA+B,QAAQ,CAAC,MAAM,IAAI,EACrD,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,YAAY,EAChD,QAAQ,CAAC,MAAM,CACf,CAAA;IACF,CAAC;IAED,2FAA2F;IAC3F,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrD,MAAM,IAAI,cAAc,CACvB,qDAAqD,EACrD,oBAAoB,EACpB,QAAQ,CAAC,MAAM,CACf,CAAA;IACF,CAAC;IAED,OAAO,OAAO,CAAA;AACf,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,SAAS,CAAC,QAAgB,EAAE,KAAoB;IAC/D,OAAO;QACN,QAAQ;QACR,WAAW,EAAE,KAAK,CAAC,YAAY;QAC/B,YAAY,EAAE,KAAK,CAAC,aAAa;QACjC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,UAAU,GAAG,IAAI;KAC/C,CAAA;AACF,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,KAAK,CAAC,EAAU,EAAE,MAAmB;IACpD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACtC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC7B,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;YAC5C,OAAO,EAAE,CAAA;QACV,CAAC,EAAE,EAAE,CAAC,CAAA;QACN,SAAS,OAAO;YACf,YAAY,CAAC,KAAK,CAAC,CAAA;YACnB,MAAM,CAAC,IAAI,cAAc,CAAC,oBAAoB,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,CAAA;QAC/D,CAAC;QACD,IAAI,MAAM,CAAC,OAAO;YAAE,OAAO,EAAE,CAAA;;YACxB,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/D,CAAC,CAAC,CAAA;AACH,CAAC","sourcesContent":["import { RasterApiError, toFetchError } from '../api/errors.js'\nimport type { OAuthSession } from '../types/session.js'\n\n/** The token endpoint's response, in OAuth's own snake_case. */\nexport type TokenResponse = {\n\taccess_token: string\n\texpires_in: number\n\trefresh_token: string\n}\n\nexport type AuthContext = {\n\tauthOrigin: string\n\tfetch: typeof globalThis.fetch\n}\n\n/**\n * POST to the authorization server. Bodies are form-encoded as OAuth expects — a \"simple\"\n * request, so no CORS preflight — except registration, whose metadata is JSON. Failures\n * come back as `{ error, error_description }` and are thrown as `RasterApiError` with the\n * OAuth error as `code`, so the poll loop can branch on `authorization_pending` and friends.\n */\nexport async function authPost<T>(\n\tcontext: AuthContext,\n\tpath: string,\n\tbody: Record<string, unknown>,\n\toptions: { json?: boolean; signal?: AbortSignal } = {}\n): Promise<T> {\n\tlet response: Response\n\ttry {\n\t\tresponse = await context.fetch(`${context.authOrigin}${path}`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': options.json ? 'application/json' : 'application/x-www-form-urlencoded',\n\t\t\t},\n\t\t\tbody: options.json\n\t\t\t\t? JSON.stringify(body)\n\t\t\t\t: new URLSearchParams(\n\t\t\t\t\t\tObject.entries(body).map(([key, value]) => [key, String(value)])\n\t\t\t\t\t).toString(),\n\t\t\t// Raster answers these itself; a redirect comes from something in between, such as a\n\t\t\t// captive portal or a protected preview. Followed, a 307 or 308 would post the body,\n\t\t\t// refresh token included, to wherever it points.\n\t\t\tredirect: 'error',\n\t\t\tsignal: options.signal,\n\t\t})\n\t} catch (caught) {\n\t\tthrow toFetchError(caught)\n\t}\n\n\tconst payload: T = await response.json().catch(() => null)\n\n\tif (!response.ok) {\n\t\tconst failure: object = typeof payload === 'object' && payload !== null ? payload : {}\n\t\tconst error = 'error' in failure ? failure.error : undefined\n\t\tconst description = 'error_description' in failure ? failure.error_description : undefined\n\t\tthrow new RasterApiError(\n\t\t\ttypeof description === 'string'\n\t\t\t\t? description\n\t\t\t\t: `Raster sign-in failed (HTTP ${response.status}).`,\n\t\t\ttypeof error === 'string' ? error : 'HTTP_ERROR',\n\t\t\tresponse.status\n\t\t)\n\t}\n\n\t// A 2xx without a JSON object came from something other than Raster, such as a login page.\n\tif (typeof payload !== 'object' || payload === null) {\n\t\tthrow new RasterApiError(\n\t\t\t'Unexpected response from the Raster sign-in server.',\n\t\t\t'MALFORMED_RESPONSE',\n\t\t\tresponse.status\n\t\t)\n\t}\n\n\treturn payload\n}\n\n/** Build the persisted session from a token response, resolving `expires_in` to a deadline. */\nexport function toSession(clientId: string, token: TokenResponse): OAuthSession {\n\treturn {\n\t\tclientId,\n\t\taccessToken: token.access_token,\n\t\trefreshToken: token.refresh_token,\n\t\texpiresAt: Date.now() + token.expires_in * 1000,\n\t}\n}\n\n/** A `setTimeout` that an `AbortSignal` can cut short, rejecting with `ABORTED`. */\nexport function sleep(ms: number, signal: AbortSignal): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst timer = setTimeout(() => {\n\t\t\tsignal.removeEventListener('abort', onAbort)\n\t\t\tresolve()\n\t\t}, ms)\n\t\tfunction onAbort() {\n\t\t\tclearTimeout(timer)\n\t\t\treject(new RasterApiError('Sign-in cancelled.', 'ABORTED', 0))\n\t\t}\n\t\tif (signal.aborted) onAbort()\n\t\telse signal.addEventListener('abort', onAbort, { once: true })\n\t})\n}\n"]}
@@ -0,0 +1,14 @@
1
+ import { type AuthContext } from './http.js';
2
+ /**
3
+ * Registration requires a redirect URI, though the device grant never redirects: the consent
4
+ * page stands in for one.
5
+ */
6
+ export declare function defaultRedirectUri(authOrigin: string): string;
7
+ /**
8
+ * Dynamic Client Registration (RFC 7591). Any client may register, and the record becomes
9
+ * permanent once a sign-in completes. `clientName` is what the consent page and Connected apps
10
+ * display (see `RasterHost.name`).
11
+ */
12
+ export declare function registerClient(context: AuthContext, options: {
13
+ clientName: string;
14
+ }): Promise<string>;
@@ -0,0 +1,21 @@
1
+ import { authPost } from './http.js';
2
+ /**
3
+ * Registration requires a redirect URI, though the device grant never redirects: the consent
4
+ * page stands in for one.
5
+ */
6
+ export function defaultRedirectUri(authOrigin) {
7
+ return `${authOrigin}/device`;
8
+ }
9
+ /**
10
+ * Dynamic Client Registration (RFC 7591). Any client may register, and the record becomes
11
+ * permanent once a sign-in completes. `clientName` is what the consent page and Connected apps
12
+ * display (see `RasterHost.name`).
13
+ */
14
+ export async function registerClient(context, options) {
15
+ const registered = await authPost(context, '/oauth/register', {
16
+ client_name: options.clientName,
17
+ redirect_uris: [defaultRedirectUri(context.authOrigin)],
18
+ }, { json: true });
19
+ return registered.client_id;
20
+ }
21
+ //# sourceMappingURL=register.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register.js","sourceRoot":"","sources":["../../src/auth/register.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,QAAQ,EAAE,MAAM,WAAW,CAAA;AAEtD;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,UAAkB;IACpD,OAAO,GAAG,UAAU,SAAS,CAAA;AAC9B,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CACnC,OAAoB,EACpB,OAA+B;IAE/B,MAAM,UAAU,GAAG,MAAM,QAAQ,CAChC,OAAO,EACP,iBAAiB,EACjB;QACC,WAAW,EAAE,OAAO,CAAC,UAAU;QAC/B,aAAa,EAAE,CAAC,kBAAkB,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;KACvD,EACD,EAAE,IAAI,EAAE,IAAI,EAAE,CACd,CAAA;IACD,OAAO,UAAU,CAAC,SAAS,CAAA;AAC5B,CAAC","sourcesContent":["import { type AuthContext, authPost } from './http.js'\n\n/**\n * Registration requires a redirect URI, though the device grant never redirects: the consent\n * page stands in for one.\n */\nexport function defaultRedirectUri(authOrigin: string): string {\n\treturn `${authOrigin}/device`\n}\n\n/**\n * Dynamic Client Registration (RFC 7591). Any client may register, and the record becomes\n * permanent once a sign-in completes. `clientName` is what the consent page and Connected apps\n * display (see `RasterHost.name`).\n */\nexport async function registerClient(\n\tcontext: AuthContext,\n\toptions: { clientName: string }\n): Promise<string> {\n\tconst registered = await authPost<{ client_id: string }>(\n\t\tcontext,\n\t\t'/oauth/register',\n\t\t{\n\t\t\tclient_name: options.clientName,\n\t\t\tredirect_uris: [defaultRedirectUri(context.authOrigin)],\n\t\t},\n\t\t{ json: true }\n\t)\n\treturn registered.client_id\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import type { CredentialStore } from '../adapters/index.js';
2
+ import { type StoredCredentials } from '../types/session.js';
3
+ import type { AuthContext } from './http.js';
4
+ /**
5
+ * The credential a client holds, and everything that keeps it valid. Each
6
+ * `createRasterClient` has its own, so clients on one page, such as one per Sanity Studio
7
+ * workspace, never share a token.
8
+ */
9
+ export interface SessionStore {
10
+ /** The credentials in memory. `undefined` until `hydrate()` has run. */
11
+ current(): StoredCredentials | undefined;
12
+ /** Load from the `CredentialStore`. Idempotent; safe to await more than once. */
13
+ hydrate(): Promise<StoredCredentials>;
14
+ /** Persist, then replace in memory. A failed write changes nothing and rejects with its error. */
15
+ save(credentials: StoredCredentials): Promise<void>;
16
+ /** Called on every change — a sign-in, a token refresh, a sign-out. */
17
+ subscribe(listener: (credentials: StoredCredentials) => void): () => void;
18
+ isSignedIn(): boolean;
19
+ /** Whether there is any credential at all (a session or an API key). */
20
+ isConfigured(): boolean;
21
+ credentialIdentity(): string | null;
22
+ getAccessToken(options?: {
23
+ rejected?: string;
24
+ }): Promise<string>;
25
+ signOut(): Promise<void>;
26
+ }
27
+ export declare function createSessionStore(options: {
28
+ credentials: CredentialStore;
29
+ auth: AuthContext;
30
+ }): SessionStore;
@@ -0,0 +1,231 @@
1
+ import { RasterApiError } from '../api/errors.js';
2
+ import { SIGNED_OUT, emptyCredentials, } from '../types/session.js';
3
+ import { refreshSession, revokeSession } from './tokens.js';
4
+ /** Refresh this far ahead of `expiresAt`, so a token is never presented right at its edge. */
5
+ const EXPIRY_MARGIN_MS = 60 * 1000;
6
+ /** One name for every client on the origin: refreshes are rare, so queueing costs little. */
7
+ const REFRESH_LOCK = 'raster-sdk:refresh';
8
+ /**
9
+ * Run a refresh under a lock every tab on the origin shares, so two tabs never rotate one
10
+ * refresh token at once. Without the Web Locks API, or where it refuses the request (an
11
+ * opaque origin such as Figma's plugin iframe), it runs unlocked: re-reading the store
12
+ * first then narrows that race rather than closing it.
13
+ */
14
+ async function withRefreshLock(refresh) {
15
+ const locks = globalThis.navigator?.locks;
16
+ if (!locks)
17
+ return refresh();
18
+ let granted = false;
19
+ try {
20
+ return await locks.request(REFRESH_LOCK, () => {
21
+ granted = true;
22
+ return refresh();
23
+ });
24
+ }
25
+ catch (caught) {
26
+ if (granted)
27
+ throw caught;
28
+ return refresh();
29
+ }
30
+ }
31
+ /**
32
+ * A 64-bit FNV-1a digest of an API key: the same for the same key, different for another in
33
+ * practice, and too short to recover the key from.
34
+ */
35
+ function keyDigest(apiKey) {
36
+ let hash = 0xcbf29ce484222325n;
37
+ for (const byte of new TextEncoder().encode(apiKey)) {
38
+ hash = ((hash ^ BigInt(byte)) * 0x100000001b3n) & 0xffffffffffffffffn;
39
+ }
40
+ return hash.toString(36);
41
+ }
42
+ export function createSessionStore(options) {
43
+ const { credentials: store, auth } = options;
44
+ let current;
45
+ /** What `save` is writing, which memory takes only once the store has it. */
46
+ let writing;
47
+ let hydrating = null;
48
+ let refreshing = null;
49
+ const listeners = new Set();
50
+ function emit(next) {
51
+ current = next;
52
+ for (const listener of listeners)
53
+ listener(next);
54
+ }
55
+ /**
56
+ * Write first, then change memory and tell listeners. A failed write leaves memory as it
57
+ * was and rejects with the store's own error, so a sign-in or sign-out the store never took
58
+ * is not in use either.
59
+ */
60
+ async function save(next) {
61
+ writing = next;
62
+ try {
63
+ await store.save(next);
64
+ }
65
+ finally {
66
+ if (writing === next)
67
+ writing = undefined;
68
+ }
69
+ emit(next);
70
+ }
71
+ /**
72
+ * Read the store, filling in the fields a credential saved before they existed lacks:
73
+ * a missing `oauth` is not `null`, so it would read as signed in.
74
+ */
75
+ async function load() {
76
+ const stored = await store.load();
77
+ return stored === null ? null : { ...emptyCredentials(), ...stored };
78
+ }
79
+ /** Signed-out credentials that keep this install's registration (see `clientId`). */
80
+ function signedOut() {
81
+ return { ...SIGNED_OUT, clientId: current?.clientId ?? null };
82
+ }
83
+ /** Whether the stored session, or the one being written, is still the one a refresh started from. */
84
+ function isSameSession(session) {
85
+ return (writing ?? current)?.oauth?.refreshToken === session.refreshToken;
86
+ }
87
+ /**
88
+ * Single-flight: a provider and a first request can both call this on mount, and two
89
+ * concurrent loads would race to `emit` — the later one winning even if it read staler
90
+ * data.
91
+ */
92
+ function hydrate() {
93
+ hydrating ?? (hydrating = load()
94
+ .then((loaded) => {
95
+ const next = loaded ?? emptyCredentials();
96
+ emit(next);
97
+ return next;
98
+ })
99
+ .catch((caught) => {
100
+ // Clear the cached promise so a transient read failure can be retried rather
101
+ // than poisoning every later call.
102
+ hydrating = null;
103
+ throw caught;
104
+ }));
105
+ return hydrating;
106
+ }
107
+ async function refresh(session) {
108
+ let next;
109
+ try {
110
+ next = await refreshSession(auth, session);
111
+ }
112
+ catch (caught) {
113
+ // The connection is gone — revoked under Connected apps, membership lost, or the
114
+ // token was spent already. Only signing in again fixes that, so drop the dead
115
+ // session now rather than fail every request with it.
116
+ if (caught instanceof RasterApiError && caught.code === 'invalid_grant') {
117
+ if (isSameSession(session))
118
+ await save(signedOut());
119
+ throw new RasterApiError('Your Raster session has ended. Sign in again.', 'SESSION_EXPIRED', 401);
120
+ }
121
+ throw caught;
122
+ }
123
+ // The old refresh token died the moment this one was minted, so memory takes the new
124
+ // one first and keeps it even if the write fails; the token is handed out only once
125
+ // written. Skipped if the session changed under us (signed out, or signed in again)
126
+ // while the refresh was in flight — that newer session must not be clobbered.
127
+ if (current !== undefined && isSameSession(session)) {
128
+ const rotated = { ...current, oauth: next };
129
+ emit(rotated);
130
+ await store.save(rotated);
131
+ }
132
+ return next;
133
+ }
134
+ /**
135
+ * The bearer the credentials can send as they stand, or the session to refresh first:
136
+ * its token is about to expire, or is the `rejected` one a request just got a 401 with.
137
+ */
138
+ function tokenOrStale(credentials, rejected) {
139
+ if (credentials === undefined || credentials.oauth === null) {
140
+ const apiKey = credentials?.apiKey;
141
+ if (!apiKey)
142
+ throw new RasterApiError('Sign in to Raster first.', 'NOT_SIGNED_IN', 401);
143
+ return apiKey;
144
+ }
145
+ const { oauth } = credentials;
146
+ const fresh = Date.now() < oauth.expiresAt - EXPIRY_MARGIN_MS;
147
+ // A rejected token that has already been replaced here: another request refreshed
148
+ // first, so this one can simply use the new token.
149
+ return fresh && oauth.accessToken !== rejected ? oauth.accessToken : oauth;
150
+ }
151
+ /**
152
+ * Refresh unless another tab sharing the store got there first. Its copy wins — it may
153
+ * have rotated the token, signed out, or signed in as somebody else — unless this tab
154
+ * changed its own credentials while waiting. A store that reads back null is unreadable
155
+ * (blocked storage), not signed out.
156
+ */
157
+ async function refreshLatest(from, rejected) {
158
+ const stored = await load();
159
+ if (stored !== null &&
160
+ current === from &&
161
+ stored.oauth?.refreshToken !== from?.oauth?.refreshToken) {
162
+ emit(stored);
163
+ }
164
+ const found = tokenOrStale(current, rejected);
165
+ return typeof found === 'string' ? found : (await refresh(found)).accessToken;
166
+ }
167
+ return {
168
+ current() {
169
+ return current;
170
+ },
171
+ hydrate,
172
+ save,
173
+ subscribe(listener) {
174
+ listeners.add(listener);
175
+ return () => {
176
+ listeners.delete(listener);
177
+ };
178
+ },
179
+ isSignedIn() {
180
+ return current !== undefined && current.oauth !== null;
181
+ },
182
+ isConfigured() {
183
+ return (current !== undefined &&
184
+ (current.oauth !== null || (typeof current.apiKey === 'string' && current.apiKey !== '')));
185
+ },
186
+ /**
187
+ * Identifies WHOSE credential is in use, so a caller can tell a token refresh apart
188
+ * from a different account signing in. Stable across refreshes and reloads, and new
189
+ * with every sign-in, even one through the same client (see `OAuthSession.signInId`).
190
+ * A session saved before sign-ins had an id falls back to its client. An API key is
191
+ * named by its digest, so the key itself never lands in a cache key or a log.
192
+ */
193
+ credentialIdentity() {
194
+ if (current === undefined)
195
+ return null;
196
+ const { oauth, apiKey } = current;
197
+ if (oauth !== null)
198
+ return `oauth:${oauth.signInId ?? oauth.clientId}`;
199
+ return apiKey === null ? null : `key:${keyDigest(apiKey)}`;
200
+ },
201
+ /**
202
+ * The bearer to send: the API key, or the session's access token, refreshed first when
203
+ * it is about to expire or is the `rejected` one. Refreshes are single-flight, because
204
+ * the server treats a second use of a rotated refresh token as theft and revokes the
205
+ * whole connection; tabs sharing a store share one too (`refreshLatest`).
206
+ */
207
+ async getAccessToken(tokenOptions = {}) {
208
+ if (current === undefined)
209
+ await hydrate();
210
+ const found = tokenOrStale(current, tokenOptions.rejected);
211
+ if (typeof found === 'string')
212
+ return found;
213
+ const from = current;
214
+ refreshing ?? (refreshing = withRefreshLock(() => refreshLatest(from, tokenOptions.rejected)).finally(() => {
215
+ refreshing = null;
216
+ }));
217
+ return refreshing;
218
+ },
219
+ /**
220
+ * Sign out: clear the session, and revoke it in Raster (best-effort — it is gone
221
+ * locally either way, and a failed revoke must not leave the user stuck signed in).
222
+ */
223
+ async signOut() {
224
+ const oauth = current?.oauth ?? null;
225
+ await save(signedOut());
226
+ if (oauth !== null)
227
+ await revokeSession(auth, oauth).catch(() => { });
228
+ },
229
+ };
230
+ }
231
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/auth/session.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CredentialStore } from '../adapters/index.js'\nimport { RasterApiError } from '../api/errors.js'\nimport {\n\ttype OAuthSession,\n\tSIGNED_OUT,\n\ttype StoredCredentials,\n\temptyCredentials,\n} from '../types/session.js'\nimport type { AuthContext } from './http.js'\nimport { refreshSession, revokeSession } from './tokens.js'\n\n/** Refresh this far ahead of `expiresAt`, so a token is never presented right at its edge. */\nconst EXPIRY_MARGIN_MS = 60 * 1000\n\n/** One name for every client on the origin: refreshes are rare, so queueing costs little. */\nconst REFRESH_LOCK = 'raster-sdk:refresh'\n\n/**\n * Run a refresh under a lock every tab on the origin shares, so two tabs never rotate one\n * refresh token at once. Without the Web Locks API, or where it refuses the request (an\n * opaque origin such as Figma's plugin iframe), it runs unlocked: re-reading the store\n * first then narrows that race rather than closing it.\n */\nasync function withRefreshLock<T>(refresh: () => Promise<T>): Promise<T> {\n\tconst locks = globalThis.navigator?.locks\n\tif (!locks) return refresh()\n\tlet granted = false\n\ttry {\n\t\treturn await locks.request(REFRESH_LOCK, () => {\n\t\t\tgranted = true\n\t\t\treturn refresh()\n\t\t})\n\t} catch (caught) {\n\t\tif (granted) throw caught\n\t\treturn refresh()\n\t}\n}\n\n/**\n * A 64-bit FNV-1a digest of an API key: the same for the same key, different for another in\n * practice, and too short to recover the key from.\n */\nfunction keyDigest(apiKey: string): string {\n\tlet hash = 0xcbf29ce484222325n\n\tfor (const byte of new TextEncoder().encode(apiKey)) {\n\t\thash = ((hash ^ BigInt(byte)) * 0x100000001b3n) & 0xffffffffffffffffn\n\t}\n\treturn hash.toString(36)\n}\n\n/**\n * The credential a client holds, and everything that keeps it valid. Each\n * `createRasterClient` has its own, so clients on one page, such as one per Sanity Studio\n * workspace, never share a token.\n */\nexport interface SessionStore {\n\t/** The credentials in memory. `undefined` until `hydrate()` has run. */\n\tcurrent(): StoredCredentials | undefined\n\t/** Load from the `CredentialStore`. Idempotent; safe to await more than once. */\n\thydrate(): Promise<StoredCredentials>\n\t/** Persist, then replace in memory. A failed write changes nothing and rejects with its error. */\n\tsave(credentials: StoredCredentials): Promise<void>\n\t/** Called on every change — a sign-in, a token refresh, a sign-out. */\n\tsubscribe(listener: (credentials: StoredCredentials) => void): () => void\n\tisSignedIn(): boolean\n\t/** Whether there is any credential at all (a session or an API key). */\n\tisConfigured(): boolean\n\tcredentialIdentity(): string | null\n\tgetAccessToken(options?: { rejected?: string }): Promise<string>\n\tsignOut(): Promise<void>\n}\n\nexport function createSessionStore(options: {\n\tcredentials: CredentialStore\n\tauth: AuthContext\n}): SessionStore {\n\tconst { credentials: store, auth } = options\n\n\tlet current: StoredCredentials | undefined\n\t/** What `save` is writing, which memory takes only once the store has it. */\n\tlet writing: StoredCredentials | undefined\n\tlet hydrating: Promise<StoredCredentials> | null = null\n\tlet refreshing: Promise<string> | null = null\n\tconst listeners = new Set<(credentials: StoredCredentials) => void>()\n\n\tfunction emit(next: StoredCredentials): void {\n\t\tcurrent = next\n\t\tfor (const listener of listeners) listener(next)\n\t}\n\n\t/**\n\t * Write first, then change memory and tell listeners. A failed write leaves memory as it\n\t * was and rejects with the store's own error, so a sign-in or sign-out the store never took\n\t * is not in use either.\n\t */\n\tasync function save(next: StoredCredentials): Promise<void> {\n\t\twriting = next\n\t\ttry {\n\t\t\tawait store.save(next)\n\t\t} finally {\n\t\t\tif (writing === next) writing = undefined\n\t\t}\n\t\temit(next)\n\t}\n\n\t/**\n\t * Read the store, filling in the fields a credential saved before they existed lacks:\n\t * a missing `oauth` is not `null`, so it would read as signed in.\n\t */\n\tasync function load(): Promise<StoredCredentials | null> {\n\t\tconst stored = await store.load()\n\t\treturn stored === null ? null : { ...emptyCredentials(), ...stored }\n\t}\n\n\t/** Signed-out credentials that keep this install's registration (see `clientId`). */\n\tfunction signedOut(): StoredCredentials {\n\t\treturn { ...SIGNED_OUT, clientId: current?.clientId ?? null }\n\t}\n\n\t/** Whether the stored session, or the one being written, is still the one a refresh started from. */\n\tfunction isSameSession(session: OAuthSession): boolean {\n\t\treturn (writing ?? current)?.oauth?.refreshToken === session.refreshToken\n\t}\n\n\t/**\n\t * Single-flight: a provider and a first request can both call this on mount, and two\n\t * concurrent loads would race to `emit` — the later one winning even if it read staler\n\t * data.\n\t */\n\tfunction hydrate(): Promise<StoredCredentials> {\n\t\thydrating ??= load()\n\t\t\t.then((loaded) => {\n\t\t\t\tconst next = loaded ?? emptyCredentials()\n\t\t\t\temit(next)\n\t\t\t\treturn next\n\t\t\t})\n\t\t\t.catch((caught) => {\n\t\t\t\t// Clear the cached promise so a transient read failure can be retried rather\n\t\t\t\t// than poisoning every later call.\n\t\t\t\thydrating = null\n\t\t\t\tthrow caught\n\t\t\t})\n\t\treturn hydrating\n\t}\n\n\tasync function refresh(session: OAuthSession): Promise<OAuthSession> {\n\t\tlet next: OAuthSession\n\t\ttry {\n\t\t\tnext = await refreshSession(auth, session)\n\t\t} catch (caught) {\n\t\t\t// The connection is gone — revoked under Connected apps, membership lost, or the\n\t\t\t// token was spent already. Only signing in again fixes that, so drop the dead\n\t\t\t// session now rather than fail every request with it.\n\t\t\tif (caught instanceof RasterApiError && caught.code === 'invalid_grant') {\n\t\t\t\tif (isSameSession(session)) await save(signedOut())\n\t\t\t\tthrow new RasterApiError(\n\t\t\t\t\t'Your Raster session has ended. Sign in again.',\n\t\t\t\t\t'SESSION_EXPIRED',\n\t\t\t\t\t401\n\t\t\t\t)\n\t\t\t}\n\t\t\tthrow caught\n\t\t}\n\n\t\t// The old refresh token died the moment this one was minted, so memory takes the new\n\t\t// one first and keeps it even if the write fails; the token is handed out only once\n\t\t// written. Skipped if the session changed under us (signed out, or signed in again)\n\t\t// while the refresh was in flight — that newer session must not be clobbered.\n\t\tif (current !== undefined && isSameSession(session)) {\n\t\t\tconst rotated = { ...current, oauth: next }\n\t\t\temit(rotated)\n\t\t\tawait store.save(rotated)\n\t\t}\n\t\treturn next\n\t}\n\n\t/**\n\t * The bearer the credentials can send as they stand, or the session to refresh first:\n\t * its token is about to expire, or is the `rejected` one a request just got a 401 with.\n\t */\n\tfunction tokenOrStale(\n\t\tcredentials: StoredCredentials | undefined,\n\t\trejected: string | undefined\n\t): string | OAuthSession {\n\t\tif (credentials === undefined || credentials.oauth === null) {\n\t\t\tconst apiKey = credentials?.apiKey\n\t\t\tif (!apiKey) throw new RasterApiError('Sign in to Raster first.', 'NOT_SIGNED_IN', 401)\n\t\t\treturn apiKey\n\t\t}\n\n\t\tconst { oauth } = credentials\n\t\tconst fresh = Date.now() < oauth.expiresAt - EXPIRY_MARGIN_MS\n\t\t// A rejected token that has already been replaced here: another request refreshed\n\t\t// first, so this one can simply use the new token.\n\t\treturn fresh && oauth.accessToken !== rejected ? oauth.accessToken : oauth\n\t}\n\n\t/**\n\t * Refresh unless another tab sharing the store got there first. Its copy wins — it may\n\t * have rotated the token, signed out, or signed in as somebody else — unless this tab\n\t * changed its own credentials while waiting. A store that reads back null is unreadable\n\t * (blocked storage), not signed out.\n\t */\n\tasync function refreshLatest(\n\t\tfrom: StoredCredentials | undefined,\n\t\trejected: string | undefined\n\t): Promise<string> {\n\t\tconst stored = await load()\n\t\tif (\n\t\t\tstored !== null &&\n\t\t\tcurrent === from &&\n\t\t\tstored.oauth?.refreshToken !== from?.oauth?.refreshToken\n\t\t) {\n\t\t\temit(stored)\n\t\t}\n\t\tconst found = tokenOrStale(current, rejected)\n\t\treturn typeof found === 'string' ? found : (await refresh(found)).accessToken\n\t}\n\n\treturn {\n\t\tcurrent() {\n\t\t\treturn current\n\t\t},\n\n\t\thydrate,\n\n\t\tsave,\n\n\t\tsubscribe(listener) {\n\t\t\tlisteners.add(listener)\n\t\t\treturn () => {\n\t\t\t\tlisteners.delete(listener)\n\t\t\t}\n\t\t},\n\n\t\tisSignedIn() {\n\t\t\treturn current !== undefined && current.oauth !== null\n\t\t},\n\n\t\tisConfigured() {\n\t\t\treturn (\n\t\t\t\tcurrent !== undefined &&\n\t\t\t\t(current.oauth !== null || (typeof current.apiKey === 'string' && current.apiKey !== ''))\n\t\t\t)\n\t\t},\n\n\t\t/**\n\t\t * Identifies WHOSE credential is in use, so a caller can tell a token refresh apart\n\t\t * from a different account signing in. Stable across refreshes and reloads, and new\n\t\t * with every sign-in, even one through the same client (see `OAuthSession.signInId`).\n\t\t * A session saved before sign-ins had an id falls back to its client. An API key is\n\t\t * named by its digest, so the key itself never lands in a cache key or a log.\n\t\t */\n\t\tcredentialIdentity() {\n\t\t\tif (current === undefined) return null\n\t\t\tconst { oauth, apiKey } = current\n\t\t\tif (oauth !== null) return `oauth:${oauth.signInId ?? oauth.clientId}`\n\t\t\treturn apiKey === null ? null : `key:${keyDigest(apiKey)}`\n\t\t},\n\n\t\t/**\n\t\t * The bearer to send: the API key, or the session's access token, refreshed first when\n\t\t * it is about to expire or is the `rejected` one. Refreshes are single-flight, because\n\t\t * the server treats a second use of a rotated refresh token as theft and revokes the\n\t\t * whole connection; tabs sharing a store share one too (`refreshLatest`).\n\t\t */\n\t\tasync getAccessToken(tokenOptions = {}) {\n\t\t\tif (current === undefined) await hydrate()\n\n\t\t\tconst found = tokenOrStale(current, tokenOptions.rejected)\n\t\t\tif (typeof found === 'string') return found\n\n\t\t\tconst from = current\n\t\t\trefreshing ??= withRefreshLock(() => refreshLatest(from, tokenOptions.rejected)).finally(\n\t\t\t\t() => {\n\t\t\t\t\trefreshing = null\n\t\t\t\t}\n\t\t\t)\n\t\t\treturn refreshing\n\t\t},\n\n\t\t/**\n\t\t * Sign out: clear the session, and revoke it in Raster (best-effort — it is gone\n\t\t * locally either way, and a failed revoke must not leave the user stuck signed in).\n\t\t */\n\t\tasync signOut() {\n\t\t\tconst oauth = current?.oauth ?? null\n\t\t\tawait save(signedOut())\n\t\t\tif (oauth !== null) await revokeSession(auth, oauth).catch(() => {})\n\t\t},\n\t}\n}\n"]}
@@ -0,0 +1,10 @@
1
+ import type { OAuthSession } from '../types/session.js';
2
+ import { type AuthContext } from './http.js';
3
+ /**
4
+ * Trade the refresh token for a new access token — and a new refresh token, since they
5
+ * rotate. `invalid_grant` means the connection is gone (revoked in Raster, membership lost,
6
+ * or the token was already spent): only a fresh sign-in can recover from that.
7
+ */
8
+ export declare function refreshSession(context: AuthContext, session: OAuthSession): Promise<OAuthSession>;
9
+ /** Revoke the connection (RFC 7009). The server answers 200 whatever the token's state. */
10
+ export declare function revokeSession(context: AuthContext, session: OAuthSession): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { authPost, toSession } from './http.js';
2
+ /**
3
+ * Trade the refresh token for a new access token — and a new refresh token, since they
4
+ * rotate. `invalid_grant` means the connection is gone (revoked in Raster, membership lost,
5
+ * or the token was already spent): only a fresh sign-in can recover from that.
6
+ */
7
+ export async function refreshSession(context, session) {
8
+ const token = await authPost(context, '/oauth/token', {
9
+ grant_type: 'refresh_token',
10
+ refresh_token: session.refreshToken,
11
+ client_id: session.clientId,
12
+ });
13
+ // A rotation replaces the tokens, not the sign-in they belong to (`signInId`).
14
+ return { ...session, ...toSession(session.clientId, token) };
15
+ }
16
+ /** Revoke the connection (RFC 7009). The server answers 200 whatever the token's state. */
17
+ export async function revokeSession(context, session) {
18
+ await authPost(context, '/oauth/revoke', { token: session.refreshToken });
19
+ }
20
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AACA,OAAO,EAAwC,QAAQ,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAErF;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CACnC,OAAoB,EACpB,OAAqB;IAErB,MAAM,KAAK,GAAG,MAAM,QAAQ,CAAgB,OAAO,EAAE,cAAc,EAAE;QACpE,UAAU,EAAE,eAAe;QAC3B,aAAa,EAAE,OAAO,CAAC,YAAY;QACnC,SAAS,EAAE,OAAO,CAAC,QAAQ;KAC3B,CAAC,CAAA;IACF,+EAA+E;IAC/E,OAAO,EAAE,GAAG,OAAO,EAAE,GAAG,SAAS,CAAC,OAAO,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAA;AAC7D,CAAC;AAED,2FAA2F;AAC3F,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAoB,EAAE,OAAqB;IAC9E,MAAM,QAAQ,CAAC,OAAO,EAAE,eAAe,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;AAC1E,CAAC","sourcesContent":["import type { OAuthSession } from '../types/session.js'\nimport { type AuthContext, type TokenResponse, authPost, toSession } from './http.js'\n\n/**\n * Trade the refresh token for a new access token — and a new refresh token, since they\n * rotate. `invalid_grant` means the connection is gone (revoked in Raster, membership lost,\n * or the token was already spent): only a fresh sign-in can recover from that.\n */\nexport async function refreshSession(\n\tcontext: AuthContext,\n\tsession: OAuthSession\n): Promise<OAuthSession> {\n\tconst token = await authPost<TokenResponse>(context, '/oauth/token', {\n\t\tgrant_type: 'refresh_token',\n\t\trefresh_token: session.refreshToken,\n\t\tclient_id: session.clientId,\n\t})\n\t// A rotation replaces the tokens, not the sign-in they belong to (`signInId`).\n\treturn { ...session, ...toSession(session.clientId, token) }\n}\n\n/** Revoke the connection (RFC 7009). The server answers 200 whatever the token's state. */\nexport async function revokeSession(context: AuthContext, session: OAuthSession): Promise<void> {\n\tawait authPost(context, '/oauth/revoke', { token: session.refreshToken })\n}\n"]}
@@ -0,0 +1,138 @@
1
+ import type { CredentialStore, RasterHost } from './adapters/index.js';
2
+ import { hasMorePages } from './api/assets.js';
3
+ import { type Me } from './api/me.js';
4
+ import { type PromoteResult } from './api/promote.js';
5
+ import { type SearchResult } from './api/search.js';
6
+ import { type Transport } from './api/transport.js';
7
+ import { type UploadSource } from './api/upload.js';
8
+ import { type DeviceAuthorization } from './auth/device.js';
9
+ import { type SessionStore } from './auth/session.js';
10
+ import type { Asset, UploadedAsset } from './types/asset.js';
11
+ import type { Library } from './types/library.js';
12
+ import type { OAuthSession, StoredCredentials } from './types/session.js';
13
+ export type RasterClientConfig = {
14
+ /** Which application this is, and how it reaches the user's browser. */
15
+ host: RasterHost;
16
+ /** Where the credential lives. See the security note on `CredentialStore`. */
17
+ credentials: CredentialStore;
18
+ /** Override for tests or a proxy. Defaults to `https://api.raster.app`. */
19
+ apiOrigin?: string;
20
+ /** Override for tests. Defaults to `https://raster.app`. */
21
+ authOrigin?: string;
22
+ /** Override `fetch`. Defaults to the global one, bound to `globalThis`. */
23
+ fetch?: typeof globalThis.fetch;
24
+ };
25
+ export interface RasterClient {
26
+ readonly host: RasterHost;
27
+ readonly session: SessionStore;
28
+ /** Escape hatch: the raw authorized transport, for endpoints the SDK has not wrapped. */
29
+ readonly transport: Transport;
30
+ /** Resolve what the current credential can reach. Doubles as credential validation. */
31
+ me(bearer?: string): Promise<Me>;
32
+ libraries: {
33
+ /** Every library in the organization, however many pages that takes. */
34
+ list(options: {
35
+ organizationId: string;
36
+ organizationName?: string | null;
37
+ signal?: AbortSignal;
38
+ }): Promise<Array<Library>>;
39
+ };
40
+ assets: {
41
+ list(options: {
42
+ organizationId: string;
43
+ libraryId: string;
44
+ page?: number;
45
+ pageSize?: number;
46
+ signal?: AbortSignal;
47
+ }): Promise<Array<Asset>>;
48
+ get(options: {
49
+ organizationId: string;
50
+ libraryId: string;
51
+ assetId: string;
52
+ signal?: AbortSignal;
53
+ }): Promise<Asset>;
54
+ upload(options: {
55
+ organizationId: string;
56
+ libraryId: string;
57
+ parentId?: string | null;
58
+ source: UploadSource;
59
+ filename?: string;
60
+ signal?: AbortSignal;
61
+ }): Promise<UploadedAsset>;
62
+ /**
63
+ * Wait until Raster has processed an upload, which is after `upload` returns. Resolves
64
+ * with the asset — for a variant, its parent, whose `variants` then carry it — or with
65
+ * null if it was still processing when the wait gave up.
66
+ */
67
+ waitUntilProcessed(options: {
68
+ organizationId: string;
69
+ libraryId: string;
70
+ assetId: string;
71
+ parentId?: string | null;
72
+ signal?: AbortSignal;
73
+ }): Promise<Asset | null>;
74
+ promote(options: {
75
+ organizationId: string;
76
+ libraryId: string;
77
+ assetId: string;
78
+ variantId: string;
79
+ signal?: AbortSignal;
80
+ }): Promise<PromoteResult>;
81
+ /**
82
+ * Wait until a promote has landed, which is after `promote` returns. Resolves with the
83
+ * asset as it then reads, or with null if it had not landed when the wait gave up.
84
+ */
85
+ waitUntilPromoted(options: {
86
+ organizationId: string;
87
+ libraryId: string;
88
+ assetId: string;
89
+ variantId: string;
90
+ signal?: AbortSignal;
91
+ }): Promise<Asset | null>;
92
+ hasMorePages: typeof hasMorePages;
93
+ };
94
+ search(options: {
95
+ organizationId: string;
96
+ query: string;
97
+ libraryIds?: Array<string>;
98
+ page?: number;
99
+ pageSize?: number;
100
+ signal?: AbortSignal;
101
+ }): Promise<SearchResult>;
102
+ auth: {
103
+ /**
104
+ * Sign in with the device grant (RFC 8628), which works in every host, a Figma plugin's
105
+ * null-origin iframe included. Opens the consent page through `host.openExternal` as
106
+ * soon as the code exists, unless `openBrowser: false` leaves that to the caller.
107
+ */
108
+ signInWithDevice(options: {
109
+ onCode: (device: DeviceAuthorization) => void;
110
+ signal: AbortSignal;
111
+ openBrowser?: boolean;
112
+ /**
113
+ * `user`, the default, lets the user grant every organization or narrow the grant to
114
+ * one on the consent page; `org` has them pick exactly one. Access tokens last 60
115
+ * minutes for one organization and 15 for every organization.
116
+ */
117
+ scope?: 'user' | 'org';
118
+ /**
119
+ * The organization the sign-in must be bound to. The consent page cannot preselect it,
120
+ * so when the user grants another, or the whole account, the session is revoked
121
+ * unsaved and the call fails with `ORGANIZATION_MISMATCH`.
122
+ */
123
+ organizationId?: string;
124
+ }): Promise<StoredCredentials>;
125
+ /**
126
+ * Adopt a credential once `/me` accepts it, and persist what `/me` reports with it (the
127
+ * account name, the organizations reached). The device sign-in ends here too. Verifying
128
+ * first makes a bad key an error on the form rather than a broken session later.
129
+ */
130
+ connect(credential: {
131
+ apiKey: string;
132
+ } | {
133
+ oauth: OAuthSession;
134
+ }): Promise<StoredCredentials>;
135
+ signOut(): Promise<void>;
136
+ };
137
+ }
138
+ export declare function createRasterClient(config: RasterClientConfig): RasterClient;