sandstormapp 0.0.0-stage → 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Superstorm, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,137 @@
1
- # Temporary Holding Version
1
+ # ads.dev
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [ads.dev](https://ads.dev) API for Node, Deno, Bun and edge runtimes: image, video and audio generation with 34 models on your brand, the brand kit and library, and the ads your competitors run. Every model's input is typed, so the model you name decides what `input` takes.
4
+
5
+ It is published as `ads.dev`, `adsdotdev`, `sandstormapp` and `@sandstormapp/sdk`, the same package under four names, and needs nothing at runtime but `fetch` and Web Crypto.
6
+
7
+ ```bash
8
+ npm install ads.dev
9
+ ```
10
+
11
+ ```ts
12
+ import { Sandstorm } from 'ads.dev'
13
+
14
+ const sandstorm = new Sandstorm()
15
+
16
+ const generation = await sandstorm.generations.run('nano-banana-pro', {
17
+ prompt: 'A ceramic mug on a sunlit bench, steam rising',
18
+ aspect_ratio: '4:5',
19
+ })
20
+ console.log(generation.output[0]?.url)
21
+ ```
22
+
23
+ ## A key
24
+
25
+ `new Sandstorm()` reads `SANDSTORM_API_KEY`. Make a key in Sandstorm under Settings → API keys, or in one command, which signs you in in the browser and writes the key to `.env`:
26
+
27
+ ```bash
28
+ curl -fsSL https://ads.dev/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs
29
+ ```
30
+
31
+ ```ts
32
+ new Sandstorm({
33
+ apiKey: process.env.SANDSTORM_API_KEY,
34
+ brand: '6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f',
35
+ baseUrl: 'https://ads.dev',
36
+ maxRetries: 2,
37
+ timeoutMs: 60_000,
38
+ })
39
+ ```
40
+
41
+ A key acts on the brand it was made on; `brand` names another brand of the same organization. `baseUrl` defaults to `SANDSTORM_API_URL`, then `https://ads.dev`. Keep the key on your server: anyone who holds it spends your credits.
42
+
43
+ ## Generations
44
+
45
+ `run` makes a generation and answers it once it has finished, holding each request on the API with `Prefer: wait` rather than polling. A generation that fails or is canceled throws `GenerationFailed`, which carries it.
46
+
47
+ ```ts
48
+ const still = await sandstorm.generations.run('nano-banana-2-lite', { prompt: 'Two lemons in a blue bowl' })
49
+
50
+ const clip = await sandstorm.generations.run('seedance-2.5/image-to-video', {
51
+ start_image: still.output[0]!.id,
52
+ prompt: 'Slow push in as the light warms',
53
+ resolution: '720p',
54
+ })
55
+ ```
56
+
57
+ Every media input takes an asset id (`asset_…`) or a public https URL, so the output of one generation goes straight into the next.
58
+
59
+ For work that runs long, queue it and come back to it, or take a webhook:
60
+
61
+ ```ts
62
+ const queued = await sandstorm.generations.create('kling-3-pro/image-to-video', input, { webhookUrl: 'https://yourapp.com/hooks/sandstorm' })
63
+ const finished = await sandstorm.generations.wait(queued.id)
64
+
65
+ await sandstorm.generations.get(queued.id)
66
+ await sandstorm.generations.cancel(queued.id)
67
+ ```
68
+
69
+ A generation is `queued`, then `running`, then `succeeded`, `failed` or `canceled`; `credits` is what it was charged once it finished, and a failed one is free. Cancelling a queued generation costs nothing; one the model has already started stops where the model allows it, and otherwise finishes and is charged.
70
+
71
+ `create` sends an `Idempotency-Key` with every call and keeps it across its retries, so a retry never makes a second generation. Pass your own to make a call safe across process restarts:
72
+
73
+ ```ts
74
+ await sandstorm.generations.create('nano-banana-pro', input, { idempotencyKey: 'campaign-42-hero' })
75
+ ```
76
+
77
+ ## Lists
78
+
79
+ Every list answers one page when awaited and walks every page when iterated:
80
+
81
+ ```ts
82
+ const page = await sandstorm.generations.list({ status: 'succeeded', limit: 50 })
83
+
84
+ for await (const asset of sandstorm.assets.list({ type: 'image' })) console.log(asset.id, asset.url)
85
+ ```
86
+
87
+ ## Everything else
88
+
89
+ ```ts
90
+ await sandstorm.models.list({ type: 'video' })
91
+ await sandstorm.models.get('nano-banana-pro/edit')
92
+
93
+ await sandstorm.brand.get()
94
+ await sandstorm.assets.import('https://yourcdn.com/hero.png', { name: 'Hero shot' })
95
+ await sandstorm.assets.get('asset_9c1e77aa')
96
+ await sandstorm.offers.list({ query: 'cold brew' })
97
+ await sandstorm.offers.get('offer_0f8e2b6a4c1d4e7f9a3b5c7d9e1f2a4b')
98
+ await sandstorm.characters.list({ kind: 'subject' })
99
+ await sandstorm.voices.list({ use_case: 'advertisement', limit: 5 })
100
+
101
+ const [advertiser] = (await sandstorm.advertisers.list({ query: 'Liquid Death' })).data
102
+ await sandstorm.ads.list({ advertiser: advertiser!.id, status: 'active' })
103
+ await sandstorm.ads.get('ad_5e1c9a3b')
104
+ ```
105
+
106
+ ## Webhooks
107
+
108
+ A generation made with `webhookUrl` is POSTed to it once it finishes, signed in `Sandstorm-Signature`. Verify the raw body before you trust it:
109
+
110
+ ```ts
111
+ import { verifyWebhook } from 'ads.dev'
112
+
113
+ const secret = await sandstorm.webhooks.secret()
114
+
115
+ const event = await verifyWebhook({ secret, body: await request.text(), signature: request.headers.get('sandstorm-signature') })
116
+ if (event.type === 'generation.succeeded') console.log(event.data.output)
117
+ ```
118
+
119
+ It refuses a body that was changed, a signature from another secret and a delivery older than five minutes. An event's `id` is the same on every retry of it, so a handler can skip one it has seen.
120
+
121
+ ## Errors
122
+
123
+ A refusal throws `SandstormError` with the HTTP `status`, its `type` and `code`, the `param` at fault and the `requestId` to quote to support. Requests Sandstorm could not take (408, 429, 500, 502, 503, 504) and network failures are retried twice with backoff, after the `Retry-After` the API asks for.
124
+
125
+ ```ts
126
+ import { SandstormError } from 'ads.dev'
127
+
128
+ try {
129
+ await sandstorm.generations.create('nano-banana-pro', { prompt: 'A mug on a bench' })
130
+ } catch (error) {
131
+ if (error instanceof SandstormError && error.code === 'insufficient_credits') console.log('Add credits in Sandstorm')
132
+ }
133
+ ```
134
+
135
+ ## Developing
136
+
137
+ `openapi.json` is the API's own document, held to what the API serves by the agent project's tests; `yarn generate` turns it into `src/openapi.ts`, and `yarn ci:test` fails when the two disagree. `yarn release` publishes the build as `ads.dev`, `adsdotdev`, `sandstormapp` and `@sandstormapp/sdk`.
@@ -0,0 +1,139 @@
1
+ import type { components, operations } from './openapi.ts';
2
+ type Schemas = components['schemas'];
3
+ export type Generation = Schemas['Generation'];
4
+ export type GenerationEvent = Schemas['GenerationEvent'];
5
+ export type Model = Schemas['Model'];
6
+ export type Asset = Schemas['Asset'];
7
+ export type LibraryAsset = Schemas['LibraryAsset'];
8
+ export type Brand = Schemas['Brand'];
9
+ export type OfferSummary = Schemas['OfferSummary'];
10
+ export type Offer = Schemas['Offer'];
11
+ export type Character = Schemas['Character'];
12
+ export type Voice = Schemas['Voice'];
13
+ export type Advertiser = Schemas['Advertiser'];
14
+ export type AdSummary = Schemas['AdSummary'];
15
+ export type Ad = Schemas['Ad'];
16
+ type CreateGeneration = Schemas['CreateGeneration'];
17
+ type Refusal = Schemas['Error']['error'];
18
+ export type ModelId = CreateGeneration['model'];
19
+ export type ModelInput<Id extends ModelId> = Extract<CreateGeneration, {
20
+ model: Id;
21
+ }>['input'];
22
+ type Query<Operation extends keyof operations> = NonNullable<operations[Operation]['parameters']['query']>;
23
+ type Flag<Value> = [Value] extends ['true' | 'false' | undefined] ? boolean : Value;
24
+ type Params<Operation extends keyof operations> = {
25
+ readonly [Name in Exclude<keyof Query<Operation>, 'cursor'>]?: Flag<Query<Operation>[Name]>;
26
+ };
27
+ export type GenerationParams = Params<'listGenerations'>;
28
+ export type ModelParams = Params<'listModels'>;
29
+ export type AssetParams = Params<'listAssets'>;
30
+ export type OfferParams = Params<'listOffers'>;
31
+ export type CharacterParams = Params<'listCharacters'>;
32
+ export type VoiceParams = Params<'listVoices'>;
33
+ export type AdvertiserParams = Params<'listAdvertisers'> & {
34
+ readonly query: string;
35
+ };
36
+ export type AdParams = Params<'listAds'>;
37
+ export interface List<Item> {
38
+ readonly object: 'list';
39
+ readonly data: Item[];
40
+ readonly has_more: boolean;
41
+ readonly next_cursor: string | null;
42
+ }
43
+ export interface Options {
44
+ readonly apiKey?: string;
45
+ readonly baseUrl?: string;
46
+ readonly brand?: string;
47
+ readonly maxRetries?: number;
48
+ readonly timeoutMs?: number;
49
+ readonly fetch?: typeof fetch;
50
+ }
51
+ export interface CreateOptions {
52
+ readonly webhookUrl?: string;
53
+ readonly wait?: number;
54
+ readonly idempotencyKey?: string;
55
+ }
56
+ export interface WaitOptions {
57
+ readonly timeoutMs?: number;
58
+ }
59
+ export declare class SandstormError extends Error {
60
+ readonly name = "SandstormError";
61
+ readonly status: number;
62
+ readonly type: Refusal['type'];
63
+ readonly code: string;
64
+ readonly param: string | undefined;
65
+ readonly requestId: string | undefined;
66
+ constructor(status: number, refusal: Omit<Refusal, 'request_id'> & {
67
+ readonly request_id?: string;
68
+ });
69
+ }
70
+ export declare class GenerationFailed extends Error {
71
+ readonly name = "GenerationFailed";
72
+ readonly generation: Generation;
73
+ constructor(generation: Generation);
74
+ }
75
+ export declare const isFinished: (generation: Generation) => boolean;
76
+ export declare class Page<Item> implements PromiseLike<List<Item>>, AsyncIterable<Item> {
77
+ #private;
78
+ constructor(read: (cursor: string | undefined) => Promise<List<Item>>);
79
+ then<Fulfilled = List<Item>, Rejected = never>(fulfilled?: ((list: List<Item>) => Fulfilled | PromiseLike<Fulfilled>) | null, rejected?: ((reason: unknown) => Rejected | PromiseLike<Rejected>) | null): Promise<Fulfilled | Rejected>;
80
+ [Symbol.asyncIterator](): AsyncIterator<Item>;
81
+ }
82
+ export declare class Sandstorm {
83
+ #private;
84
+ constructor({ apiKey, baseUrl, brand, maxRetries, timeoutMs, fetch: fetcher, }?: Options);
85
+ readonly generations: {
86
+ create: <Id extends ModelId>(model: Id, input: ModelInput<Id>, { webhookUrl, wait, idempotencyKey }?: CreateOptions) => Promise<Generation>;
87
+ get: (id: string, { wait }?: {
88
+ readonly wait?: number;
89
+ }) => Promise<Generation>;
90
+ wait: (id: string, { timeoutMs }?: WaitOptions) => Promise<Generation>;
91
+ run: <Id extends ModelId>(model: Id, input: ModelInput<Id>, { timeoutMs, ...options }?: Omit<CreateOptions, "wait"> & WaitOptions) => Promise<Generation>;
92
+ cancel: (id: string) => Promise<Generation>;
93
+ list: (params?: GenerationParams) => Page<Generation>;
94
+ };
95
+ readonly models: {
96
+ list: (params?: ModelParams) => Page<Model>;
97
+ get: (id: ModelId) => Promise<Model>;
98
+ };
99
+ readonly assets: {
100
+ list: (params?: AssetParams) => Page<LibraryAsset>;
101
+ get: (id: string) => Promise<Asset>;
102
+ import: (url: string, { name, idempotencyKey }?: {
103
+ readonly name?: string;
104
+ readonly idempotencyKey?: string;
105
+ }) => Promise<Asset>;
106
+ };
107
+ readonly brand: {
108
+ get: () => Promise<Brand>;
109
+ };
110
+ readonly offers: {
111
+ list: (params?: OfferParams) => Page<OfferSummary>;
112
+ get: (id: string) => Promise<Offer>;
113
+ };
114
+ readonly characters: {
115
+ list: (params?: CharacterParams) => Page<Character>;
116
+ };
117
+ readonly voices: {
118
+ list: (params?: VoiceParams) => Page<Voice>;
119
+ };
120
+ readonly advertisers: {
121
+ list: (params: AdvertiserParams) => Page<Advertiser>;
122
+ };
123
+ readonly ads: {
124
+ list: (params?: AdParams) => Page<AdSummary>;
125
+ get: (id: string) => Promise<Ad>;
126
+ };
127
+ readonly webhooks: {
128
+ secret: () => Promise<string>;
129
+ };
130
+ }
131
+ export interface Delivery {
132
+ readonly secret: string;
133
+ readonly body: string;
134
+ readonly signature: string | null;
135
+ readonly toleranceSeconds?: number;
136
+ readonly now?: number;
137
+ }
138
+ export declare function verifyWebhook({ secret, body, signature, toleranceSeconds, now }: Delivery): Promise<GenerationEvent>;
139
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,194 @@
1
+ export class SandstormError extends Error {
2
+ name = 'SandstormError';
3
+ status;
4
+ type;
5
+ code;
6
+ param;
7
+ requestId;
8
+ constructor(status, refusal) {
9
+ super(refusal.message);
10
+ this.status = status;
11
+ this.type = refusal.type;
12
+ this.code = refusal.code;
13
+ this.param = refusal.param;
14
+ this.requestId = refusal.request_id;
15
+ }
16
+ }
17
+ export class GenerationFailed extends Error {
18
+ name = 'GenerationFailed';
19
+ generation;
20
+ constructor(generation) {
21
+ super(generation.error?.message ?? `${generation.id} was ${generation.status}`);
22
+ this.generation = generation;
23
+ }
24
+ }
25
+ const BASE_URL = 'https://ads.dev';
26
+ const LONGEST_WAIT_S = 60;
27
+ const RUN_TIMEOUT_MS = 60 * 60_000;
28
+ const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
29
+ const FINISHED = new Set(['succeeded', 'failed', 'canceled']);
30
+ export const isFinished = (generation) => FINISHED.has(generation.status);
31
+ const environment = (name) => globalThis.process?.env?.[name];
32
+ export class Page {
33
+ #read;
34
+ constructor(read) {
35
+ this.#read = read;
36
+ }
37
+ then(fulfilled, rejected) {
38
+ return this.#read(undefined).then(fulfilled, rejected);
39
+ }
40
+ async *[Symbol.asyncIterator]() {
41
+ for (let cursor;;) {
42
+ const page = await this.#read(cursor);
43
+ yield* page.data;
44
+ if (!page.has_more || page.next_cursor === null)
45
+ return;
46
+ cursor = page.next_cursor;
47
+ }
48
+ }
49
+ }
50
+ export class Sandstorm {
51
+ #baseUrl;
52
+ #headers;
53
+ #maxRetries;
54
+ #timeoutMs;
55
+ #fetch;
56
+ constructor({ apiKey = environment('SANDSTORM_API_KEY'), baseUrl = environment('SANDSTORM_API_URL') ?? BASE_URL, brand, maxRetries = 2, timeoutMs = 60_000, fetch: fetcher = (input, init) => fetch(input, init), } = {}) {
57
+ this.#baseUrl = baseUrl.replace(/\/+$/, '');
58
+ if (apiKey === undefined || apiKey === '') {
59
+ throw new Error(`Pass apiKey or set SANDSTORM_API_KEY. To make a key: curl -fsSL ${this.#baseUrl}/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs`);
60
+ }
61
+ this.#headers = { authorization: `Bearer ${apiKey}`, ...(brand === undefined ? {} : { 'sandstorm-brand': brand }) };
62
+ this.#maxRetries = maxRetries;
63
+ this.#timeoutMs = timeoutMs;
64
+ this.#fetch = fetcher;
65
+ }
66
+ generations = {
67
+ create: (model, input, { webhookUrl, wait, idempotencyKey = crypto.randomUUID() } = {}) => this.#send({
68
+ method: 'POST',
69
+ path: '/v1/generations',
70
+ body: { model, input, ...(webhookUrl === undefined ? {} : { webhook_url: webhookUrl }) },
71
+ ...(wait === undefined ? {} : { wait }),
72
+ idempotencyKey,
73
+ }),
74
+ get: (id, { wait } = {}) => this.#send({ method: 'GET', path: `/v1/generations/${encodeURIComponent(id)}`, ...(wait === undefined ? {} : { wait }) }),
75
+ wait: async (id, { timeoutMs = RUN_TIMEOUT_MS } = {}) => {
76
+ const deadline = Date.now() + timeoutMs;
77
+ for (;;) {
78
+ const left = Math.ceil((deadline - Date.now()) / 1000);
79
+ const generation = await this.generations.get(id, { wait: Math.max(1, Math.min(LONGEST_WAIT_S, left)) });
80
+ if (isFinished(generation))
81
+ return generation;
82
+ if (left <= 0)
83
+ throw new Error(`${id} did not finish within ${timeoutMs} ms; it is still ${generation.status}`);
84
+ }
85
+ },
86
+ run: async (model, input, { timeoutMs, ...options } = {}) => {
87
+ const created = await this.generations.create(model, input, { ...options, wait: LONGEST_WAIT_S });
88
+ const generation = isFinished(created) ? created : await this.generations.wait(created.id, timeoutMs === undefined ? {} : { timeoutMs });
89
+ if (generation.status !== 'succeeded')
90
+ throw new GenerationFailed(generation);
91
+ return generation;
92
+ },
93
+ cancel: (id) => this.#send({ method: 'POST', path: `/v1/generations/${encodeURIComponent(id)}/cancel` }),
94
+ list: (params = {}) => this.#list('/v1/generations', params),
95
+ };
96
+ models = {
97
+ list: (params = {}) => this.#list('/v1/models', params),
98
+ get: (id) => this.#send({ method: 'GET', path: `/v1/models/${encodeURIComponent(id)}` }),
99
+ };
100
+ assets = {
101
+ list: (params = {}) => this.#list('/v1/assets', params),
102
+ get: (id) => this.#send({ method: 'GET', path: `/v1/assets/${encodeURIComponent(id)}` }),
103
+ import: (url, { name, idempotencyKey = crypto.randomUUID() } = {}) => this.#send({ method: 'POST', path: '/v1/assets', body: { url, ...(name === undefined ? {} : { name }) }, idempotencyKey }),
104
+ };
105
+ brand = {
106
+ get: () => this.#send({ method: 'GET', path: '/v1/brand' }),
107
+ };
108
+ offers = {
109
+ list: (params = {}) => this.#list('/v1/offers', params),
110
+ get: (id) => this.#send({ method: 'GET', path: `/v1/offers/${encodeURIComponent(id)}` }),
111
+ };
112
+ characters = {
113
+ list: (params = {}) => this.#list('/v1/characters', params),
114
+ };
115
+ voices = {
116
+ list: (params = {}) => this.#list('/v1/voices', params),
117
+ };
118
+ advertisers = {
119
+ list: (params) => this.#list('/v1/advertisers', params),
120
+ };
121
+ ads = {
122
+ list: (params = {}) => this.#list('/v1/ads', params),
123
+ get: (id) => this.#send({ method: 'GET', path: `/v1/ads/${encodeURIComponent(id)}` }),
124
+ };
125
+ webhooks = {
126
+ secret: async () => (await this.#send({ method: 'GET', path: '/v1/webhooks/secret' })).secret,
127
+ };
128
+ #list(path, params) {
129
+ return new Page((cursor) => this.#send({ method: 'GET', path, query: cursor === undefined ? params : { ...params, cursor } }));
130
+ }
131
+ async #send(request) {
132
+ const url = new URL(this.#baseUrl + request.path);
133
+ for (const [name, value] of Object.entries(request.query ?? {}))
134
+ if (value !== undefined)
135
+ url.searchParams.set(name, String(value));
136
+ const headers = {
137
+ ...this.#headers,
138
+ ...(request.body === undefined ? {} : { 'content-type': 'application/json' }),
139
+ ...(request.wait === undefined ? {} : { prefer: `wait=${request.wait}` }),
140
+ ...(request.idempotencyKey === undefined ? {} : { 'idempotency-key': request.idempotencyKey }),
141
+ };
142
+ const body = request.body === undefined ? null : JSON.stringify(request.body);
143
+ const send = this.#fetch;
144
+ for (let attempt = 0;; attempt++) {
145
+ const timeout = AbortSignal.timeout(this.#timeoutMs + (request.wait ?? 0) * 1000);
146
+ const answered = await send(url, { method: request.method, headers, body, signal: timeout }).catch((error) => error instanceof Error ? error : new Error(String(error)));
147
+ if (answered instanceof Response && answered.ok && isJson(answered))
148
+ return (await answered.json());
149
+ if (answered instanceof Response && answered.ok)
150
+ throw await notTheApi(answered, url.origin);
151
+ const retryable = answered instanceof Error || RETRYABLE.has(answered.status);
152
+ if (!retryable || attempt >= this.#maxRetries)
153
+ throw answered instanceof Error ? answered : await refusalOf(answered, url.origin);
154
+ await new Promise((resolve) => setTimeout(resolve, delayOf(attempt, answered instanceof Response ? answered : undefined)));
155
+ }
156
+ }
157
+ }
158
+ function delayOf(attempt, response) {
159
+ const after = Number(response?.headers.get('retry-after'));
160
+ if (Number.isFinite(after) && after > 0)
161
+ return after * 1000;
162
+ return Math.min(8_000, 500 * 2 ** attempt) * (0.75 + Math.random() / 2);
163
+ }
164
+ const isJson = (response) => response.headers.get('content-type')?.includes('json') === true;
165
+ async function notTheApi(response, origin) {
166
+ await response.body?.cancel();
167
+ return new SandstormError(response.status, {
168
+ type: 'api_error',
169
+ code: 'not_the_api',
170
+ message: `${origin} answered ${response.status} with ${response.headers.get('content-type') ?? 'no content type'} instead of the API's JSON. If ${origin} is not the Sandstorm API, pass baseUrl or set SANDSTORM_API_URL.`,
171
+ });
172
+ }
173
+ async function refusalOf(response, origin) {
174
+ if (!isJson(response))
175
+ return notTheApi(response, origin);
176
+ return new SandstormError(response.status, (await response.json()).error);
177
+ }
178
+ const HEX = /^[0-9a-f]{64}$/;
179
+ export async function verifyWebhook({ secret, body, signature, toleranceSeconds = 300, now = Date.now() }) {
180
+ const parts = new Map((signature ?? '').split(',').map((part) => [part.slice(0, part.indexOf('=')), part.slice(part.indexOf('=') + 1)]));
181
+ const timestamp = Number(parts.get('t'));
182
+ const given = parts.get('v1') ?? '';
183
+ if (!Number.isInteger(timestamp) || !HEX.test(given))
184
+ throw new Error('The Sandstorm-Signature header is missing or malformed.');
185
+ if (Math.abs(now / 1000 - timestamp) > toleranceSeconds)
186
+ throw new Error('The webhook is older than the tolerance; refuse it as a possible replay.');
187
+ const encoder = new TextEncoder();
188
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
189
+ const bytes = Uint8Array.from(given.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16));
190
+ if (!(await crypto.subtle.verify('HMAC', key, bytes, encoder.encode(`${timestamp}.${body}`)))) {
191
+ throw new Error('The webhook signature does not match; it was not sent by Sandstorm with this secret.');
192
+ }
193
+ return JSON.parse(body);
194
+ }