@zappyquote/sdk 0.0.0-stage → 0.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 +21 -0
- package/README.md +53 -2
- package/index.d.ts +61 -0
- package/index.mjs +204 -0
- package/package.json +20 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zappy
|
|
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,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zappyquote/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The official JavaScript/TypeScript client for the Zappy API v1 - a thin wrapper over the documented HTTP API
|
|
4
|
+
(https://zappyquote.com/developers/). The server stays authoritative for pricing, caps, idempotency and ownership;
|
|
5
|
+
this package only carries requests and maps the public error envelope into typed errors.
|
|
6
|
+
|
|
7
|
+
Version 0.1.0 · contract `openapi-1.0.0@9dff9fdfbcd8c1bd` · Node 18+ · zero dependencies · types included · MIT
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# official download (always available):
|
|
11
|
+
curl -O https://zappyquote.com/dl/zappy.mjs # types: https://zappyquote.com/dl/zappy.d.ts
|
|
12
|
+
# npm, ONLY once https://zappyquote.com/developers/ lists @zappyquote/sdk as published by Zappy:
|
|
13
|
+
# npm install @zappyquote/sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
import { Zappy, ZappyError, newIdempotencyKey } from './zappy.mjs'; // or from '@zappyquote/sdk' when installed from npm
|
|
18
|
+
|
|
19
|
+
const zappy = new Zappy(); // reads ZAPPY_API_KEY; or new Zappy({ apiKey })
|
|
20
|
+
const { capabilities } = await zappy.capabilities(); // free
|
|
21
|
+
const quote = await zappy.quote({ capability: 'zappy-image-pro' }); // free, reserves nothing
|
|
22
|
+
|
|
23
|
+
// BILLABLE. maxCredits is a hard ceiling; the idempotency key makes a replay return the same job, never a second charge.
|
|
24
|
+
const key = newIdempotencyKey('order-123');
|
|
25
|
+
const r = await zappy.createImage({ prompt: 'a lighthouse at dusk', maxCredits: quote.credits_quoted, idempotencyKey: key });
|
|
26
|
+
const job = r.status === 'succeeded' ? r : await zappy.waitForJob(r.job_id); // 202 => poll (free)
|
|
27
|
+
const asset = await zappy.getAsset(job.assets[0].asset_id); // short-lived signed URL on Zappy's host
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Errors
|
|
31
|
+
|
|
32
|
+
Every failure is a `ZappyError` subclass with `code` (the public error code), `status`, `requestId`, `rateLimit`
|
|
33
|
+
(`{limit, remaining, reset}`) and `retryAfter`: `ZappyAuthError` (401 / `insufficient_scope` / not configured),
|
|
34
|
+
`ZappyRateLimitError` (429), `ZappySpendError` (ceiling, cap, balance, idempotency), `ZappyNotFoundError`,
|
|
35
|
+
`ZappyUnavailableError` (502/503, not available), `ZappyTransportError` (network, timeout, unreadable). Messages never
|
|
36
|
+
contain a credential. Successful results carry `_meta` with the same metadata.
|
|
37
|
+
|
|
38
|
+
## Webhooks
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { verifyWebhookSignature } from './zappy.mjs'; // or '@zappyquote/sdk'
|
|
42
|
+
// rawBody must be the exact bytes received; header is the Zappy-Signature value
|
|
43
|
+
if (!verifyWebhookSignature(process.env.ZAPPY_WEBHOOK_SECRET, req.headers['zappy-signature'], rawBody)) return res.status(400).end();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## OAuth (PKCE)
|
|
47
|
+
|
|
48
|
+
`pkcePair()` and `authorizeUrl({...})` build the authorization request for an OAuth app you registered in Zappy
|
|
49
|
+
(Account -> Developer). Token exchange and consent happen on Zappy's authorization server; this package adds no
|
|
50
|
+
second auth implementation. Pass `resource: 'https://zappyquote.com/mcp'` for a Remote MCP connection.
|
|
51
|
+
|
|
52
|
+
## Paid calls are never retried
|
|
53
|
+
|
|
54
|
+
`createImage` / `editImage` send one request. A retry is your decision - make it with the same `idempotencyKey`.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Type definitions for @zappyquote/sdk 0.1.0 - thin client for the Zappy API v1 (contract openapi-1.0.0@9dff9fdfbcd8c1bd).
|
|
2
|
+
export const SDK_VERSION: string;
|
|
3
|
+
export const CONTRACT: string;
|
|
4
|
+
export const DEFAULT_BASE_URL: string;
|
|
5
|
+
|
|
6
|
+
export interface RateLimit { limit: number | null; remaining: number | null; reset: number | null; }
|
|
7
|
+
export interface ResponseMeta { requestId: string | null; rateLimit: RateLimit | null; retryAfter: number | null; status?: number; }
|
|
8
|
+
|
|
9
|
+
export type ErrorCode =
|
|
10
|
+
| 'balance_unavailable' | 'busy' | 'capability_retired' | 'daily_cap_reached' | 'delivery_unavailable'
|
|
11
|
+
| 'idempotency_conflict' | 'idempotency_key_required' | 'insufficient_credits' | 'insufficient_scope' | 'key_busy'
|
|
12
|
+
| 'key_misconfigured' | 'not_available' | 'pilot_closed' | 'rate_limited' | 'request_exceeds_key_ceiling'
|
|
13
|
+
| 'request_key_retired' | 'spend_check_unavailable' | 'unauthorized' | 'unsupported_duration' | 'unsupported_parameter'
|
|
14
|
+
| 'not_found' | 'unreachable' | 'bad_response' | 'not_configured' | 'wait_timeout' | string;
|
|
15
|
+
|
|
16
|
+
export class ZappyError extends Error { code: ErrorCode; status: number; requestId: string | null; rateLimit: RateLimit | null; retryAfter: number | null; body: unknown;
|
|
17
|
+
/** Set on errors thrown by createImage/editImage: the key that was sent. Replay with it to recover a lost response safely. */
|
|
18
|
+
idempotencyKey?: string; }
|
|
19
|
+
export class ZappyAuthError extends ZappyError {}
|
|
20
|
+
export class ZappyRateLimitError extends ZappyError {}
|
|
21
|
+
export class ZappySpendError extends ZappyError {}
|
|
22
|
+
export class ZappyNotFoundError extends ZappyError {}
|
|
23
|
+
export class ZappyUnavailableError extends ZappyError {}
|
|
24
|
+
export class ZappyTransportError extends ZappyError {}
|
|
25
|
+
|
|
26
|
+
export interface Capability { capability: string; credits: number; unit: string; operation: string; callable: boolean; async: boolean; }
|
|
27
|
+
export interface CapabilitiesResponse { ok: true; capabilities: Capability[]; }
|
|
28
|
+
export interface BalanceResponse { ok: true; balance: number; daily_free: number; }
|
|
29
|
+
export interface UsageResponse { ok: true; usage: { key_prefix: string; credits_spent_today: number | null; daily_credits_cap: number | null; credits_remaining_today: number | null; max_credits_per_request: number | null; rate_limit_per_min: number | null; jobs_today: { succeeded: number | null; failed: number | null; processing: number | null } }; }
|
|
30
|
+
export interface QuoteResponse { ok: true; capability: string; credits_quoted: number; }
|
|
31
|
+
export interface AssetRef { asset_id: string; kind: string; }
|
|
32
|
+
export interface JobResponse { ok: true; job_id: string; status: 'processing' | 'succeeded' | 'failed' | string; capability?: string; reason?: string; credits_quoted?: number; credits_charged?: number; credits_restored?: number; assets?: AssetRef[]; retry_after?: number; }
|
|
33
|
+
export interface AssetResponse { ok: true; asset_id: string; kind: string; mime: string; bytes: number; sha256: string; url: string; expires_at: string; }
|
|
34
|
+
export interface CreationsResponse { ok: true; items: JobResponse[]; next_cursor: string | null; }
|
|
35
|
+
export type WithMeta<T> = T & { readonly _meta: ResponseMeta };
|
|
36
|
+
|
|
37
|
+
export interface ZappyOptions { apiKey?: string; accessToken?: string; baseUrl?: string; clientName?: string; clientType?: 'sdk' | 'cli'; clientVersion?: string; timeoutMs?: number; fetch?: typeof fetch; }
|
|
38
|
+
|
|
39
|
+
export class Zappy {
|
|
40
|
+
constructor(opts?: ZappyOptions);
|
|
41
|
+
baseUrl: string; clientName: string; clientType: string; clientVersion: string; timeoutMs: number; lastMeta: ResponseMeta | null;
|
|
42
|
+
capabilities(): Promise<WithMeta<CapabilitiesResponse>>;
|
|
43
|
+
balance(): Promise<WithMeta<BalanceResponse>>;
|
|
44
|
+
usage(): Promise<WithMeta<UsageResponse>>;
|
|
45
|
+
quote(p: { capability: string; duration?: number }): Promise<WithMeta<QuoteResponse>>;
|
|
46
|
+
getJob(jobId: string): Promise<WithMeta<JobResponse>>;
|
|
47
|
+
getAsset(assetId: string): Promise<WithMeta<AssetResponse>>;
|
|
48
|
+
listCreations(p?: { limit?: number; cursor?: string }): Promise<WithMeta<CreationsResponse>>;
|
|
49
|
+
waitForJob(jobId: string, p?: { intervalMs?: number; timeoutMs?: number; onPoll?: (j: JobResponse) => void }): Promise<WithMeta<JobResponse>>;
|
|
50
|
+
/** SPENDS CREDITS. Never retried automatically; replay with the same idempotencyKey returns the same job. */
|
|
51
|
+
createImage(p: { prompt: string; maxCredits: number; aspectRatio?: string; idempotencyKey?: string; onKey?: (key: string) => void }): Promise<WithMeta<JobResponse> & { idempotencyKey: string }>;
|
|
52
|
+
/** SPENDS CREDITS. `onKey` is called with the key BEFORE dispatch, so it can be persisted; the key is also on any error thrown. */
|
|
53
|
+
editImage(p: { sourceAssetId: string; prompt: string; maxCredits: number; aspectRatio?: string; idempotencyKey?: string; onKey?: (key: string) => void }): Promise<WithMeta<JobResponse> & { idempotencyKey: string }>;
|
|
54
|
+
}
|
|
55
|
+
export default Zappy;
|
|
56
|
+
|
|
57
|
+
export function redact(s: string): string;
|
|
58
|
+
export function newIdempotencyKey(prefix?: string): string;
|
|
59
|
+
export function verifyWebhookSignature(secret: string, header: string, rawBody: string | Uint8Array, opts?: { now?: number; windowSeconds?: number }): boolean;
|
|
60
|
+
export function pkcePair(): { verifier: string; challenge: string; method: 'S256' };
|
|
61
|
+
export function authorizeUrl(p: { clientId: string; redirectUri: string; scope: string; state: string; challenge: string; resource?: string; issuer?: string }): string;
|
package/index.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zappyquote/sdk - the official JavaScript/TypeScript client for the Zappy API v1.
|
|
3
|
+
*
|
|
4
|
+
* A THIN WRAPPER over the documented HTTP API (https://zappyquote.com/developers/). It holds no business rule:
|
|
5
|
+
* pricing, quotas, ceilings, idempotency and ownership are decided by the server, and this file only carries the
|
|
6
|
+
* request there and maps the public error envelope back into typed errors. Zero dependencies; Node >= 18 (fetch).
|
|
7
|
+
*
|
|
8
|
+
* Safety properties this client keeps:
|
|
9
|
+
* - a credential is read from the constructor or ZAPPY_API_KEY, is never logged, and is redacted from every error;
|
|
10
|
+
* - paid calls (createImage, editImage) are NEVER retried on their own: a retry of a paid call is the caller's
|
|
11
|
+
* decision, made with the SAME idempotency key, so a replay can only ever return the same job;
|
|
12
|
+
* - every mutating call sends an Idempotency-Key (yours, or one this client generates and returns to you);
|
|
13
|
+
* - rate-limit metadata (RateLimit-*, Retry-After) and the X-Request-Id are exposed on every result and error.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomBytes, timingSafeEqual, createHmac } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export const SDK_VERSION = '0.1.0';
|
|
18
|
+
/** sha256 prefix of the public_html/api/openapi.json this client was written against (see DEVELOPER_DISTRIBUTION.md). */
|
|
19
|
+
export const CONTRACT = 'openapi-1.0.0@9dff9fdfbcd8c1bd';
|
|
20
|
+
export const DEFAULT_BASE_URL = 'https://zappyquote.com/api/v1';
|
|
21
|
+
|
|
22
|
+
const KEY_RE = /(zp_live_|zat_|zrt_|zcs_)[A-Za-z0-9_-]+/g; // no leading boundary: an embedded credential is still a credential
|
|
23
|
+
/** Replace any Zappy credential shape in a string with a redacted marker. Used on every error message and log line. */
|
|
24
|
+
export function redact(s) { return String(s).replace(KEY_RE, (m, p) => p + '…redacted'); }
|
|
25
|
+
|
|
26
|
+
/** A stable idempotency key for ONE intended operation. Keep it with the operation and reuse it to replay safely. */
|
|
27
|
+
export function newIdempotencyKey(prefix = 'zq') {
|
|
28
|
+
const p = String(prefix).replace(/[^A-Za-z0-9._:-]/g, '').slice(0, 20) || 'zq';
|
|
29
|
+
return `${p}-${Date.now().toString(36)}-${randomBytes(9).toString('base64url')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ZappyError extends Error {
|
|
33
|
+
constructor(message, { code = 'error', status = 0, requestId = null, rateLimit = null, retryAfter = null, body = null } = {}) {
|
|
34
|
+
super(redact(message));
|
|
35
|
+
this.name = 'ZappyError'; this.code = code; this.status = status; this.requestId = requestId;
|
|
36
|
+
this.rateLimit = rateLimit; this.retryAfter = retryAfter; this.body = body;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class ZappyAuthError extends ZappyError { constructor(m, o) { super(m, o); this.name = 'ZappyAuthError'; } }
|
|
40
|
+
export class ZappyRateLimitError extends ZappyError { constructor(m, o) { super(m, o); this.name = 'ZappyRateLimitError'; } }
|
|
41
|
+
export class ZappySpendError extends ZappyError { constructor(m, o) { super(m, o); this.name = 'ZappySpendError'; } }
|
|
42
|
+
export class ZappyNotFoundError extends ZappyError { constructor(m, o) { super(m, o); this.name = 'ZappyNotFoundError'; } }
|
|
43
|
+
export class ZappyUnavailableError extends ZappyError { constructor(m, o) { super(m, o); this.name = 'ZappyUnavailableError'; } }
|
|
44
|
+
export class ZappyTransportError extends ZappyError { constructor(m, o) { super(m, o); this.name = 'ZappyTransportError'; } }
|
|
45
|
+
|
|
46
|
+
const SPEND_CODES = new Set(['insufficient_credits', 'daily_cap_reached', 'request_exceeds_key_ceiling', 'request_exceeds_ceiling', 'spend_check_unavailable', 'idempotency_key_required', 'idempotency_conflict']);
|
|
47
|
+
function errorFor(status, body, meta) {
|
|
48
|
+
const code = body?.error?.code || (status === 429 ? 'rate_limited' : status === 401 ? 'unauthorized' : status === 404 ? 'not_found' : 'error');
|
|
49
|
+
const message = body?.error?.message || `Zappy API answered HTTP ${status}`;
|
|
50
|
+
const o = { code, status, ...meta, body };
|
|
51
|
+
if (status === 401 || code === 'unauthorized') return new ZappyAuthError(message, o);
|
|
52
|
+
if (status === 403 && code === 'insufficient_scope') return new ZappyAuthError(message, o);
|
|
53
|
+
if (status === 429 && code === 'rate_limited') return new ZappyRateLimitError(message, o);
|
|
54
|
+
if (SPEND_CODES.has(code)) return new ZappySpendError(message, o);
|
|
55
|
+
if (status === 404) return new ZappyNotFoundError(message, o);
|
|
56
|
+
if (status === 503 || status === 502 || code === 'not_available' || code === 'pilot_closed') return new ZappyUnavailableError(message, o);
|
|
57
|
+
return new ZappyError(message, o);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readMeta(res) {
|
|
61
|
+
const h = res.headers;
|
|
62
|
+
const n = (k) => { const v = h.get(k); return v === null || v === '' || Number.isNaN(Number(v)) ? null : Number(v); };
|
|
63
|
+
const limit = n('ratelimit-limit'), remaining = n('ratelimit-remaining'), reset = n('ratelimit-reset');
|
|
64
|
+
return { requestId: h.get('x-request-id'), retryAfter: n('retry-after'),
|
|
65
|
+
rateLimit: limit === null && remaining === null ? null : { limit, remaining, reset } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class Zappy {
|
|
69
|
+
/**
|
|
70
|
+
* @param {{apiKey?: string, accessToken?: string, baseUrl?: string, clientName?: string, clientType?: string, timeoutMs?: number, fetch?: typeof fetch}} [opts]
|
|
71
|
+
*/
|
|
72
|
+
constructor(opts = {}) {
|
|
73
|
+
const token = opts.accessToken || opts.apiKey || process.env.ZAPPY_API_KEY || '';
|
|
74
|
+
if (!token) throw new ZappyAuthError('No credential: pass { apiKey } or set ZAPPY_API_KEY. Create a key in Zappy under Account -> Developer.', { code: 'not_configured' });
|
|
75
|
+
Object.defineProperty(this, '_token', { value: token, enumerable: false, writable: false });
|
|
76
|
+
this.baseUrl = String(opts.baseUrl || process.env.ZAPPY_API_BASE || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
77
|
+
this.clientType = opts.clientType === 'cli' ? 'cli' : 'sdk';
|
|
78
|
+
this.clientName = String(opts.clientName || 'zappy-sdk-js').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 64) || 'zappy-sdk-js';
|
|
79
|
+
this.clientVersion = /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.test(String(opts.clientVersion || '')) ? String(opts.clientVersion) : SDK_VERSION;
|
|
80
|
+
this.timeoutMs = Number(opts.timeoutMs || 60000);
|
|
81
|
+
this._fetch = opts.fetch || globalThis.fetch;
|
|
82
|
+
this.lastMeta = null;
|
|
83
|
+
}
|
|
84
|
+
/** Never reveals the credential - a redacted description only. */
|
|
85
|
+
toJSON() { return { baseUrl: this.baseUrl, clientName: this.clientName, credential: 'redacted' }; }
|
|
86
|
+
toString() { return `Zappy(${this.baseUrl})`; }
|
|
87
|
+
|
|
88
|
+
async _request(method, path, { body = null, idempotencyKey = null, query = null } = {}) {
|
|
89
|
+
let url = this.baseUrl + '/' + path.replace(/^\/+/, '');
|
|
90
|
+
if (query) { const q = new URLSearchParams(); for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null) q.set(k, String(v)); const s = q.toString(); if (s) url += '?' + s; }
|
|
91
|
+
const headers = { 'Authorization': 'Bearer ' + this._token, 'Accept': 'application/json',
|
|
92
|
+
'X-Zappy-Client-Type': this.clientType, 'X-Zappy-Client-Name': this.clientName, 'X-Zappy-Client-Version': this.clientVersion };
|
|
93
|
+
const init = { method, headers, redirect: 'manual' };
|
|
94
|
+
if (body !== null) { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
95
|
+
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
|
96
|
+
// F5-003: the timeout covers the WHOLE exchange (connect, headers and body), and every transport failure is a ZappyTransportError
|
|
97
|
+
const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), this.timeoutMs); init.signal = ac.signal;
|
|
98
|
+
let res, text, meta = null;
|
|
99
|
+
try {
|
|
100
|
+
res = await this._fetch(url, init);
|
|
101
|
+
meta = { ...readMeta(res), status: res.status }; this.lastMeta = meta;
|
|
102
|
+
// a redirect is never followed (redirect: 'manual'), so the credential can only ever reach the configured base URL
|
|
103
|
+
if (res.status >= 300 && res.status < 400) throw new ZappyTransportError('Zappy API answered with a redirect (HTTP ' + res.status + '), which this client never follows', { code: 'bad_response', status: res.status, ...meta });
|
|
104
|
+
text = await res.text();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e instanceof ZappyError) throw e;
|
|
107
|
+
throw new ZappyTransportError('Could not reach the Zappy API: ' + (e && e.name === 'AbortError' ? 'timeout' : (e && e.message) || 'network error'), { code: 'unreachable', ...(meta || {}) });
|
|
108
|
+
} finally { clearTimeout(timer); }
|
|
109
|
+
let json = null; try { json = JSON.parse(text); } catch { json = null; }
|
|
110
|
+
if (!res.ok || !json || json.ok === false) {
|
|
111
|
+
if (!json) throw new ZappyTransportError('Zappy returned an unreadable response (HTTP ' + res.status + ')', { code: 'bad_response', status: res.status, ...meta });
|
|
112
|
+
throw errorFor(res.status, json, meta);
|
|
113
|
+
}
|
|
114
|
+
return Object.defineProperty(json, '_meta', { value: { ...meta, status: res.status }, enumerable: false });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** F5-BILL-001: a paid call's key is attached to EVERY outcome, including the error, so a lost response can be replayed safely. */
|
|
118
|
+
async _paid(path, body, key, onKey) {
|
|
119
|
+
if (onKey) onKey(key);
|
|
120
|
+
try { const r = await this._request('POST', path, { body, idempotencyKey: key }); r.idempotencyKey = key; return r; }
|
|
121
|
+
catch (e) { if (e && typeof e === 'object') e.idempotencyKey = key; throw e; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---- read (free) ---- */
|
|
125
|
+
capabilities() { return this._request('GET', 'capabilities'); }
|
|
126
|
+
balance() { return this._request('GET', 'balance'); }
|
|
127
|
+
usage() { return this._request('GET', 'usage'); }
|
|
128
|
+
quote({ capability, duration } = {}) { return this._request('POST', 'quote', { body: duration ? { capability, duration } : { capability } }); }
|
|
129
|
+
getJob(jobId) { return this._request('GET', 'jobs/' + encodeURIComponent(String(jobId))); }
|
|
130
|
+
getAsset(assetId) { return this._request('GET', 'assets/' + encodeURIComponent(String(assetId))); }
|
|
131
|
+
listCreations({ limit, cursor } = {}) { return this._request('GET', 'creations', { query: { limit, cursor } }); }
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Poll a job until it is terminal (succeeded/failed) or `timeoutMs` elapses. Reads are free; polls respect the
|
|
135
|
+
* server's Retry-After when it sends one. Returns the final job record.
|
|
136
|
+
*/
|
|
137
|
+
async waitForJob(jobId, { intervalMs = 3000, timeoutMs = 600000, onPoll = null } = {}) {
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
for (;;) {
|
|
140
|
+
const j = await this.getJob(jobId);
|
|
141
|
+
if (onPoll) onPoll(j);
|
|
142
|
+
const st = String(j.status || j.job?.status || '');
|
|
143
|
+
if (st === 'succeeded' || st === 'failed') return j;
|
|
144
|
+
if (Date.now() - start > timeoutMs) throw new ZappyError('Timed out waiting for job ' + jobId + ' (it is still ' + (st || 'processing') + '; keep polling getJob)', { code: 'wait_timeout' });
|
|
145
|
+
const ra = j._meta?.retryAfter; await new Promise(r => setTimeout(r, ra ? ra * 1000 : intervalMs));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ---- write (SPENDS CREDITS; never auto-retried) ---- */
|
|
150
|
+
/**
|
|
151
|
+
* @param {{prompt: string, maxCredits: number, aspectRatio?: string, idempotencyKey?: string}} p
|
|
152
|
+
* @returns {Promise<object & {idempotencyKey: string}>} the API response (200 finished or 202 processing) plus the key used
|
|
153
|
+
*/
|
|
154
|
+
async createImage({ prompt, maxCredits, aspectRatio, idempotencyKey, onKey } = {}) {
|
|
155
|
+
if (!prompt) throw new ZappyError('prompt is required', { code: 'prompt_required' });
|
|
156
|
+
if (!(Number(maxCredits) > 0)) throw new ZappyError('maxCredits is required: a paid call needs a hard credit ceiling', { code: 'max_credits_required' });
|
|
157
|
+
const key = idempotencyKey || newIdempotencyKey('img');
|
|
158
|
+
const body = { prompt, max_credits: Number(maxCredits) }; if (aspectRatio) body.aspect_ratio = aspectRatio;
|
|
159
|
+
return this._paid('images', body, key, onKey);
|
|
160
|
+
}
|
|
161
|
+
async editImage({ sourceAssetId, prompt, maxCredits, aspectRatio, idempotencyKey, onKey } = {}) {
|
|
162
|
+
if (!sourceAssetId || !prompt) throw new ZappyError('sourceAssetId and prompt are required', { code: 'source_required' });
|
|
163
|
+
if (!(Number(maxCredits) > 0)) throw new ZappyError('maxCredits is required: a paid call needs a hard credit ceiling', { code: 'max_credits_required' });
|
|
164
|
+
const key = idempotencyKey || newIdempotencyKey('edit');
|
|
165
|
+
const body = { source_asset_id: sourceAssetId, prompt, max_credits: Number(maxCredits) }; if (aspectRatio) body.aspect_ratio = aspectRatio;
|
|
166
|
+
return this._paid('images/edit', body, key, onKey);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ---- webhooks (zq.webhook.v1) ---- */
|
|
171
|
+
/**
|
|
172
|
+
* Verify a Zappy webhook delivery. `header` is the Zappy-Signature value (t=..,v1=..), `rawBody` the exact bytes received.
|
|
173
|
+
* Returns true only for an authentic delivery inside the replay window (default 300 s). Compares in constant time.
|
|
174
|
+
*/
|
|
175
|
+
export function verifyWebhookSignature(secret, header, rawBody, { now = Math.floor(Date.now() / 1000), windowSeconds = 300 } = {}) {
|
|
176
|
+
if (typeof secret !== 'string' || !secret || typeof header !== 'string') return false;
|
|
177
|
+
let t = null; const sigs = [];
|
|
178
|
+
for (const part of header.split(',')) {
|
|
179
|
+
const i = part.indexOf('='); if (i < 0) continue;
|
|
180
|
+
const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
|
|
181
|
+
if (k === 't' && /^[0-9]+$/.test(v)) t = Number(v);
|
|
182
|
+
if (k === 'v1' && /^[a-f0-9]{64}$/.test(v)) sigs.push(v);
|
|
183
|
+
}
|
|
184
|
+
if (t === null || !sigs.length || Math.abs(now - t) > windowSeconds) return false;
|
|
185
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
|
|
186
|
+
const want = createHmac('sha256', secret).update(t + '.').update(body).digest('hex');
|
|
187
|
+
const w = Buffer.from(want, 'hex');
|
|
188
|
+
return sigs.some(s => { const b = Buffer.from(s, 'hex'); return b.length === w.length && timingSafeEqual(b, w); });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ---- OAuth 2.1 PKCE helpers (pure functions; the authorization server stays the ONE auth implementation) ---- */
|
|
192
|
+
export function pkcePair() {
|
|
193
|
+
const verifier = randomBytes(48).toString('base64url');
|
|
194
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
195
|
+
return { verifier, challenge, method: 'S256' };
|
|
196
|
+
}
|
|
197
|
+
/** Build the /oauth/authorize URL for an owner-registered Zappy OAuth app. `resource` is the API by default; pass
|
|
198
|
+
* 'https://zappyquote.com/mcp' for a Remote MCP connection (the app must have been registered as MCP-capable). */
|
|
199
|
+
export function authorizeUrl({ clientId, redirectUri, scope, state, challenge, resource = 'https://zappyquote.com/api/v1', issuer = 'https://zappyquote.com' }) {
|
|
200
|
+
const q = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope, state, code_challenge: challenge, code_challenge_method: 'S256', resource });
|
|
201
|
+
return issuer.replace(/\/+$/, '') + '/oauth/authorize?' + q.toString();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default Zappy;
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zappyquote/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official Zappy API client for JavaScript/TypeScript - a thin wrapper over the documented Zappy API v1.",
|
|
5
|
+
"keywords": ["zappy", "ai", "image-generation", "api", "sdk"],
|
|
6
|
+
"homepage": "https://zappyquote.com/developers/",
|
|
7
|
+
"bugs": { "url": "https://zappyquote.com/contact.html" },
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "Zappy",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "index.mjs",
|
|
12
|
+
"types": "index.d.ts",
|
|
13
|
+
"exports": { ".": { "types": "./index.d.ts", "import": "./index.mjs" } },
|
|
14
|
+
"files": ["index.mjs", "index.d.ts", "README.md", "LICENSE"],
|
|
15
|
+
"engines": { "node": ">=18.0.0" },
|
|
16
|
+
"repository": { "type": "git", "url": "https://zappyquote.com/developers/" },
|
|
17
|
+
"publishConfig": { "access": "public" },
|
|
18
|
+
"dependencies": {},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node test.mjs"
|
|
21
|
+
}
|
|
22
|
+
}
|