@upvoiced/sdk 0.0.0-stage → 1.32.1

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 Joao Pereira
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,160 @@
1
- # Temporary Holding Version
1
+ # @upvoiced/sdk
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
+ Typed TypeScript client for the [UpVoiced](https://upvoiced.com) API. Every path, parameter,
4
+ request body and response type is generated from the API's OpenAPI 3.1 document
5
+ (`/openapi.json`), and a thin `fetch` wrapper adds the API key, `X-Workspace`,
6
+ `Idempotency-Key`, retries, cursor pagination and typed errors. No runtime dependencies: it needs
7
+ only a standard `fetch` (Node 22+, Deno, Bun, Cloudflare Workers). API keys are secrets: use them
8
+ from your server, never from a browser.
9
+
10
+ ```sh
11
+ npm install @upvoiced/sdk
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ The initial `0.1.0` preview uses the approved staging API contract. Set `baseUrl` explicitly;
17
+ publishing the SDK does not certify production API availability. Use a key from your staging
18
+ workspace. The client retains its production-origin default for later production use.
19
+
20
+ ```ts
21
+ import { UpVoiced, UpVoicedError } from '@upvoiced/sdk';
22
+
23
+ const upvoiced = new UpVoiced({
24
+ baseUrl: 'https://api-staging.upvoiced.com',
25
+ apiKey: process.env.UPVOICED_API_KEY, // from the staging workspace's Settings → API keys
26
+ workspace: process.env.UPVOICED_WORKSPACE_ID, // sent as X-Workspace
27
+ });
28
+
29
+ const { items: boards } = await upvoiced.get('/v1/boards');
30
+
31
+ const post = await upvoiced.post('/v1/posts', {
32
+ body: { board_id: boards[0].id, title: 'Dark mode', body_md: 'From our CRM.' },
33
+ });
34
+
35
+ await upvoiced.patch('/v1/posts/{id}', { path: { id: post.id }, body: { title: 'Dark mode' } });
36
+
37
+ for await (const item of upvoiced.paginate('/v1/posts', { query: { sort: 'top', limit: 100 } })) {
38
+ console.log(item.title, item.vote_count);
39
+ }
40
+ ```
41
+
42
+ Paths are the API's own (`/v1/posts/{id}`), so the [API reference](https://docs.upvoiced.com)
43
+ and `curl` examples map one to one. Your editor completes paths, query parameters and bodies,
44
+ and TypeScript rejects a missing path parameter, an unknown field or a wrong enum value.
45
+
46
+ ## Client options
47
+
48
+ | Option | Default | Meaning |
49
+ | ----------------- | -------------------------- | ------------------------------------------------------------------------ |
50
+ | `apiKey` | — | API key, sent as `Authorization: Bearer`. Omit for `/v1/public/*` reads. |
51
+ | `workspace` | — | Workspace id (`X-Workspace`); a workspace slug for `/v1/public/*`. |
52
+ | `baseUrl` | `https://api.upvoiced.com` | API origin. |
53
+ | `maxRetries` | `2` | Retries after 429, 502/503/504 and network errors (see below). |
54
+ | `maxRetryDelayMs` | `60000` | A `Retry-After` longer than this is not waited for; the error is thrown. |
55
+ | `fetch` | global `fetch` | Custom fetch (proxies, tests). |
56
+ | `headers` | — | Extra headers on every request. |
57
+
58
+ Every call also accepts `workspace`, `idempotencyKey`, `headers` and an `AbortSignal` (`signal`).
59
+
60
+ ### Keys, scopes and workspaces
61
+
62
+ Keys carry the `read`, `write` or `admin` scope. A workspace-bound key works on its workspace
63
+ only; an operator-wide key ("all workspaces I admin") picks one per call:
64
+
65
+ ```ts
66
+ const other = upvoiced.withWorkspace(otherWorkspaceId);
67
+ await upvoiced.get('/v1/workspace', { workspace: otherWorkspaceId });
68
+ ```
69
+
70
+ Key management, workspace creation and other session-only routes refuse API keys
71
+ (`403 session_required`). Each key may send 600 GET and 120 other requests per minute.
72
+
73
+ ### Idempotency and retries
74
+
75
+ The API deduplicates POSTs by `Idempotency-Key` for 24 hours. With retries on (the default),
76
+ the client sends a generated key on every POST and reuses it on each retry, so a POST whose
77
+ response was lost is answered from the first attempt instead of being applied twice. Pass your
78
+ own `idempotencyKey` to make a POST safe across process restarts too.
79
+
80
+ The client retries a 429 for any method (the request was refused before it ran), and a 502, 503,
81
+ 504 or network error for GET, PUT, DELETE and POST. It never retries a PATCH after a 5xx. It
82
+ waits for `Retry-After` when present, else an exponential backoff with jitter.
83
+
84
+ ### Errors
85
+
86
+ Every non-2xx response throws an `UpVoicedError` with the API's error shape:
87
+
88
+ ```ts
89
+ try {
90
+ await upvoiced.post('/v1/posts', { body: { board_id, title: '' } });
91
+ } catch (error) {
92
+ if (error instanceof UpVoicedError) {
93
+ error.status; // 400
94
+ error.code; // 'invalid_request' | 'insufficient_scope' | 'not_found' | 'rate_limited' | …
95
+ error.details; // [{ path: 'title', code: 'too_small' }]
96
+ error.retryAfter; // seconds, for 429/503
97
+ error.idempotencyKey; // reuse it to retry the same POST later
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### Status and headers
103
+
104
+ `request()` resolves with the status and headers as well as the data:
105
+
106
+ ```ts
107
+ const { data, status, headers } = await upvoiced.request('post', '/v1/posts', {
108
+ body,
109
+ idempotencyKey: 'import-2026-10-22-row-17',
110
+ });
111
+ headers.get('idempotent-replayed'); // 'true' when answered from an earlier attempt
112
+ ```
113
+
114
+ ### Types
115
+
116
+ The generated OpenAPI types are exported for your own code:
117
+
118
+ ```ts
119
+ import type { Operation, RequestBody, ResponseData, paths } from '@upvoiced/sdk';
120
+
121
+ type Post = ResponseData<Operation<'/v1/posts/{id}', 'get'>>;
122
+ type NewPost = RequestBody<Operation<'/v1/posts', 'post'>>;
123
+ ```
124
+
125
+ ## Versioning
126
+
127
+ `/v1` changes are additive only (breaking changes go to `/v2` with a 12-month overlap). The
128
+ package version follows UpVoiced releases; each release is regenerated from that release's API.
129
+
130
+ ## Development (UpVoiced monorepo)
131
+
132
+ `src/generated/schema.ts` is generated, never edited: after changing API routes run
133
+ `pnpm --filter @upvoiced/sdk generate` (needs a built `@upvoiced/api`). The SDK's tests fail
134
+ while the committed file differs from the API. See ADR-0067.
135
+
136
+ ### Authenticated export files
137
+
138
+ When export detail returns `download.method = "authenticated"`, use
139
+ `downloadExport` with the detail's original size and expected format. It performs
140
+ one scoped request and returns an ArrayBuffer without text decoding or automatic
141
+ GET retries. The ordinary JSON methods refuse this binary route.
142
+
143
+ ```ts
144
+ const detail = await upvoiced.get('/v1/exports/{id}', { path: { id: exportId } });
145
+ if (detail.download?.method === 'authenticated' && detail.bytes) {
146
+ const mime =
147
+ detail.format === 'json'
148
+ ? 'application/json'
149
+ : detail.include.length > 1
150
+ ? 'application/zip'
151
+ : 'text/csv';
152
+ const bytes = await upvoiced.downloadExport({ id: detail.id, bytes: detail.bytes, mime });
153
+ // Store or consume the original bytes with detail.filename.
154
+ }
155
+ ```
156
+
157
+ The client binds the content path to its configured API origin and current workspace.
158
+ A failed or interrupted request is surfaced to the caller; an explicit later user
159
+ action may request a new current-domain read. Legacy signed-link metadata remains
160
+ compatible while protected storage is disabled.
@@ -0,0 +1,83 @@
1
+ import { type DownloadedImage, type DownloadedSocialImage } from './image-download.js';
2
+ import type { ApiResponse, HttpMethod, Operation, PageItem, PaginatedPath, PathsWith, RequestArgs, ResponseData } from './types.js';
3
+ /** Production API origin (ADR-0015). Staging: https://api-staging.upvoiced.com */
4
+ export declare const defaultBaseUrl = "https://api.upvoiced.com";
5
+ export declare const defaultMaxRetries = 2;
6
+ export declare const defaultMaxRetryDelayMs = 60000;
7
+ export interface ClientOptions {
8
+ /** API key (`uv_live_…` / `uv_test_…`), sent as `Authorization: Bearer`. Omit for public reads. */
9
+ apiKey?: string;
10
+ /** Workspace id (X-Workspace) for operator routes; a slug for `/v1/public/*`. */
11
+ workspace?: string;
12
+ /** API origin; defaults to production. */
13
+ baseUrl?: string;
14
+ /** fetch implementation (Node 22+, browsers, Workers and Deno have one). */
15
+ fetch?: (input: string, init: RequestInit) => Promise<Response>;
16
+ /**
17
+ * Retries after 429 (any method; the API refuses before running the handler), and after
18
+ * 502/503/504 or a network error for GET, PUT, DELETE and POST (POSTs always carry an
19
+ * Idempotency-Key when retries are on). PATCH is never retried after a 5xx. Default 2.
20
+ */
21
+ maxRetries?: number;
22
+ /** A Retry-After longer than this is not waited for: the error is thrown. Default 60 s. */
23
+ maxRetryDelayMs?: number;
24
+ /** Extra headers on every request. */
25
+ headers?: Record<string, string>;
26
+ }
27
+ /**
28
+ * P2-015 (ADR-0067, INT-004): thin typed client over `fetch`. Paths, parameters, bodies and
29
+ * responses are typed from the generated OpenAPI `paths`; at runtime it only adds credentials,
30
+ * X-Workspace, Idempotency-Key, retries, cursor pagination and the error shape.
31
+ *
32
+ * ```ts
33
+ * const upvoiced = new UpVoiced({ apiKey: process.env.UPVOICED_API_KEY, workspace });
34
+ * const post = await upvoiced.post('/v1/posts', { body: { board_id, title: 'Dark mode' } });
35
+ * for await (const item of upvoiced.paginate('/v1/posts', { query: { board: board_id } })) {}
36
+ * ```
37
+ */
38
+ export declare class UpVoiced {
39
+ #private;
40
+ constructor(options?: ClientOptions);
41
+ /** PRF-004 one explicit approved-quote attachment, never a JSON GET retry. */
42
+ downloadTestimonialImage(input: {
43
+ readonly id: string;
44
+ readonly size?: '1200x630' | '1080x1080' | '1080x1350';
45
+ readonly format?: 'png' | 'svg';
46
+ readonly scheme?: 'workspace' | 'light' | 'dark';
47
+ readonly signal?: AbortSignal;
48
+ }): Promise<DownloadedSocialImage>;
49
+ /** Binary content uses exactly one authenticated request; JSON GET retry policy never applies. */
50
+ downloadExport(input: {
51
+ readonly id: string;
52
+ readonly bytes: number;
53
+ readonly mime: 'text/csv' | 'application/json' | 'application/zip';
54
+ readonly signal?: AbortSignal;
55
+ }): Promise<ArrayBuffer>;
56
+ /** One authenticated read of the current operator's own stored pre-attachment logo. */
57
+ downloadBrandingPreview(input: {
58
+ readonly id: string;
59
+ readonly bytes: number;
60
+ readonly mime: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/svg+xml';
61
+ readonly signal?: AbortSignal;
62
+ }): Promise<ArrayBuffer>;
63
+ /** Current-member attached changelog image; one bounded request and no JSON retry. */
64
+ downloadChangelogImage(input: {
65
+ readonly entryId: string;
66
+ readonly assetId: string;
67
+ readonly signal?: AbortSignal;
68
+ }): Promise<DownloadedImage>;
69
+ /** A client with the same settings acting on another workspace (operator-wide keys). */
70
+ withWorkspace(workspace: string): UpVoiced;
71
+ get<P extends PathsWith<'get'>>(path: P, ...args: RequestArgs<Operation<P, 'get'>>): Promise<ResponseData<Operation<P, "get">>>;
72
+ post<P extends PathsWith<'post'>>(path: P, ...args: RequestArgs<Operation<P, 'post'>>): Promise<ResponseData<Operation<P, "post">>>;
73
+ put<P extends PathsWith<'put'>>(path: P, ...args: RequestArgs<Operation<P, 'put'>>): Promise<ResponseData<Operation<P, "put">>>;
74
+ patch<P extends PathsWith<'patch'>>(path: P, ...args: RequestArgs<Operation<P, 'patch'>>): Promise<ResponseData<Operation<P, "patch">>>;
75
+ delete<P extends PathsWith<'delete'>>(path: P, ...args: RequestArgs<Operation<P, 'delete'>>): Promise<ResponseData<Operation<P, "delete">>>;
76
+ /** Like the method helpers, but resolves with `{ data, status, headers }`. */
77
+ request<M extends HttpMethod, P extends PathsWith<M>>(method: M, path: P, ...args: RequestArgs<Operation<P, M>>): Promise<ApiResponse<ResponseData<Operation<P, M>>>>;
78
+ /**
79
+ * Every item of a cursor-paginated list (`{ items, next_cursor }`), fetching pages lazily.
80
+ * `query.limit` is the page size; stop iterating to stop fetching.
81
+ */
82
+ paginate<P extends PaginatedPath>(path: P, ...args: RequestArgs<Operation<P, 'get'>>): AsyncGenerator<PageItem<P>, void, undefined>;
83
+ }
package/dist/client.js ADDED
@@ -0,0 +1,309 @@
1
+ import { downloadImageBytes, downloadSocialImageBytes, } from './image-download.js';
2
+ import { downloadExportBytes } from './export-download.js';
3
+ import { UpVoicedError, errorShape, parseRetryAfter } from './errors.js';
4
+ /** Production API origin (ADR-0015). Staging: https://api-staging.upvoiced.com */
5
+ export const defaultBaseUrl = 'https://api.upvoiced.com';
6
+ export const defaultMaxRetries = 2;
7
+ export const defaultMaxRetryDelayMs = 60_000;
8
+ const methods = ['get', 'put', 'post', 'patch', 'delete'];
9
+ const retryableStatus = new Set([502, 503, 504]);
10
+ function expandPath(template, values) {
11
+ return template.replace(/\{([^}]+)\}/g, (_, name) => {
12
+ const value = values?.[name];
13
+ if (value === undefined || value === '')
14
+ throw new TypeError(`Missing path parameter "${name}" for ${template}.`);
15
+ return encodeURIComponent(String(value));
16
+ });
17
+ }
18
+ const queryValue = (item) => item instanceof Date
19
+ ? item.toISOString()
20
+ : typeof item === 'string'
21
+ ? item
22
+ : typeof item === 'number' || typeof item === 'boolean' || typeof item === 'bigint'
23
+ ? String(item)
24
+ : JSON.stringify(item);
25
+ function searchOf(query) {
26
+ const search = new URLSearchParams();
27
+ for (const [name, value] of Object.entries(query ?? {})) {
28
+ if (value === undefined || value === null)
29
+ continue;
30
+ for (const item of Array.isArray(value) ? value : [value])
31
+ search.append(name, queryValue(item));
32
+ }
33
+ const text = search.toString();
34
+ return text ? `?${text}` : '';
35
+ }
36
+ async function bodyOf(response) {
37
+ const text = await response.text();
38
+ if (text === '')
39
+ return undefined;
40
+ const type = response.headers.get('content-type') ?? '';
41
+ if (/\bjson\b/i.test(type)) {
42
+ try {
43
+ return JSON.parse(text);
44
+ }
45
+ catch {
46
+ return text;
47
+ }
48
+ }
49
+ return text;
50
+ }
51
+ function sleep(ms, signal) {
52
+ return new Promise((resolve, reject) => {
53
+ if (signal?.aborted) {
54
+ reject(signal.reason);
55
+ return;
56
+ }
57
+ const abort = () => {
58
+ clearTimeout(timer);
59
+ reject(signal?.reason);
60
+ };
61
+ const timer = setTimeout(() => {
62
+ signal?.removeEventListener('abort', abort);
63
+ resolve();
64
+ }, ms);
65
+ signal?.addEventListener('abort', abort, { once: true });
66
+ });
67
+ }
68
+ /** Exponential backoff with jitter: ~0.5 s, 1 s, 2 s … capped at 8 s. */
69
+ const backoff = (attempt) => Math.min(8000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
70
+ /**
71
+ * P2-015 (ADR-0067, INT-004): thin typed client over `fetch`. Paths, parameters, bodies and
72
+ * responses are typed from the generated OpenAPI `paths`; at runtime it only adds credentials,
73
+ * X-Workspace, Idempotency-Key, retries, cursor pagination and the error shape.
74
+ *
75
+ * ```ts
76
+ * const upvoiced = new UpVoiced({ apiKey: process.env.UPVOICED_API_KEY, workspace });
77
+ * const post = await upvoiced.post('/v1/posts', { body: { board_id, title: 'Dark mode' } });
78
+ * for await (const item of upvoiced.paginate('/v1/posts', { query: { board: board_id } })) {}
79
+ * ```
80
+ */
81
+ export class UpVoiced {
82
+ #options;
83
+ #baseUrl;
84
+ #maxRetries;
85
+ #maxRetryDelayMs;
86
+ constructor(options = {}) {
87
+ const base = new URL(options.baseUrl ?? defaultBaseUrl);
88
+ if (base.protocol !== 'https:' && base.protocol !== 'http:')
89
+ throw new TypeError(`baseUrl must be http(s), got ${base.protocol}`);
90
+ const maxRetries = options.maxRetries ?? defaultMaxRetries;
91
+ if (!Number.isInteger(maxRetries) || maxRetries < 0)
92
+ throw new TypeError('maxRetries must be a non-negative integer.');
93
+ const maxRetryDelayMs = options.maxRetryDelayMs ?? defaultMaxRetryDelayMs;
94
+ if (!(maxRetryDelayMs >= 0))
95
+ throw new TypeError('maxRetryDelayMs must be ≥ 0.');
96
+ this.#options = options;
97
+ this.#baseUrl = base.href.replace(/\/+$/, '');
98
+ this.#maxRetries = maxRetries;
99
+ this.#maxRetryDelayMs = maxRetryDelayMs;
100
+ }
101
+ /** PRF-004 one explicit approved-quote attachment, never a JSON GET retry. */
102
+ async downloadTestimonialImage(input) {
103
+ const size = input.size ?? '1200x630', format = input.format ?? 'png', scheme = input.scheme ?? 'workspace';
104
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(input.id) ||
105
+ !['1200x630', '1080x1080', '1080x1350'].includes(size) ||
106
+ !['png', 'svg'].includes(format) ||
107
+ !['workspace', 'light', 'dark'].includes(scheme) ||
108
+ !this.#options.apiKey ||
109
+ !this.#options.workspace)
110
+ throw new TypeError('A valid testimonial, image size/format, API key and workspace are required.');
111
+ const headers = new Headers(this.#options.headers);
112
+ headers.set('authorization', 'Bearer ' + this.#options.apiKey);
113
+ headers.set('x-workspace', this.#options.workspace);
114
+ const image = await downloadSocialImageBytes({
115
+ url: this.#baseUrl +
116
+ '/v1/testimonials/' +
117
+ input.id +
118
+ '/image?' +
119
+ new URLSearchParams({ size, format, scheme }).toString(),
120
+ headers,
121
+ ...(this.#options.fetch ? { fetch: this.#options.fetch } : {}),
122
+ ...(input.signal ? { signal: input.signal } : {}),
123
+ });
124
+ if (image.mime !== (format === 'png' ? 'image/png' : 'image/svg+xml')) {
125
+ new Uint8Array(image.buffer).fill(0);
126
+ throw new Error('The image format was unexpected.');
127
+ }
128
+ return image;
129
+ }
130
+ /** Binary content uses exactly one authenticated request; JSON GET retry policy never applies. */
131
+ async downloadExport(input) {
132
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(input.id) ||
133
+ !this.#options.apiKey ||
134
+ !this.#options.workspace)
135
+ throw new TypeError('An export ID, API key and workspace are required.');
136
+ const headers = new Headers(this.#options.headers);
137
+ headers.set('authorization', 'Bearer ' + this.#options.apiKey);
138
+ headers.set('x-workspace', this.#options.workspace);
139
+ return downloadExportBytes({
140
+ url: this.#baseUrl + '/v1/exports/' + input.id + '/content',
141
+ headers,
142
+ bytes: input.bytes,
143
+ mime: input.mime,
144
+ ...(this.#options.fetch ? { fetch: this.#options.fetch } : {}),
145
+ ...(input.signal ? { signal: input.signal } : {}),
146
+ });
147
+ }
148
+ /** One authenticated read of the current operator's own stored pre-attachment logo. */
149
+ async downloadBrandingPreview(input) {
150
+ const { id, bytes, mime, signal } = input;
151
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id) ||
152
+ !this.#options.apiKey ||
153
+ !this.#options.workspace ||
154
+ !Number.isSafeInteger(bytes) ||
155
+ bytes < 1 ||
156
+ bytes > 2097152 ||
157
+ !['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'].includes(mime))
158
+ throw new TypeError('A logo ID, bounded image metadata, API key and workspace are required.');
159
+ const headers = new Headers(this.#options.headers);
160
+ headers.set('authorization', 'Bearer ' + this.#options.apiKey);
161
+ headers.set('x-workspace', this.#options.workspace);
162
+ return downloadExportBytes({
163
+ url: this.#baseUrl + '/v1/branding/previews/' + id,
164
+ headers,
165
+ bytes,
166
+ mime,
167
+ ...(this.#options.fetch ? { fetch: this.#options.fetch } : {}),
168
+ ...(signal ? { signal } : {}),
169
+ });
170
+ }
171
+ /** Current-member attached changelog image; one bounded request and no JSON retry. */
172
+ async downloadChangelogImage(input) {
173
+ const { entryId, assetId, signal } = input;
174
+ if (![entryId, assetId].every((id) => /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id)) ||
175
+ !this.#options.apiKey ||
176
+ !this.#options.workspace)
177
+ throw new TypeError('An entry ID, image ID, API key and workspace are required.');
178
+ const headers = new Headers(this.#options.headers);
179
+ headers.set('authorization', 'Bearer ' + this.#options.apiKey);
180
+ headers.set('x-workspace', this.#options.workspace);
181
+ return downloadImageBytes({
182
+ url: this.#baseUrl + '/v1/changelog/' + entryId + '/media/' + assetId,
183
+ headers,
184
+ ...(this.#options.fetch ? { fetch: this.#options.fetch } : {}),
185
+ ...(signal ? { signal } : {}),
186
+ });
187
+ }
188
+ /** A client with the same settings acting on another workspace (operator-wide keys). */
189
+ withWorkspace(workspace) {
190
+ return new UpVoiced({ ...this.#options, workspace });
191
+ }
192
+ get(path, ...args) {
193
+ return this.#data('get', path, args[0]);
194
+ }
195
+ post(path, ...args) {
196
+ return this.#data('post', path, args[0]);
197
+ }
198
+ put(path, ...args) {
199
+ return this.#data('put', path, args[0]);
200
+ }
201
+ patch(path, ...args) {
202
+ return this.#data('patch', path, args[0]);
203
+ }
204
+ delete(path, ...args) {
205
+ return this.#data('delete', path, args[0]);
206
+ }
207
+ /** Like the method helpers, but resolves with `{ data, status, headers }`. */
208
+ request(method, path, ...args) {
209
+ return this.#send(method, path, args[0]);
210
+ }
211
+ /**
212
+ * Every item of a cursor-paginated list (`{ items, next_cursor }`), fetching pages lazily.
213
+ * `query.limit` is the page size; stop iterating to stop fetching.
214
+ */
215
+ async *paginate(path, ...args) {
216
+ const options = (args[0] ?? {});
217
+ let cursor = options.query?.['cursor'];
218
+ for (;;) {
219
+ const query = { ...options.query, ...(cursor ? { cursor } : {}) };
220
+ const { data } = await this.#send('get', path, { ...options, query });
221
+ const page = data;
222
+ yield* page.items;
223
+ if (!page.next_cursor)
224
+ return;
225
+ if (page.next_cursor === cursor)
226
+ throw new Error(`Pagination of ${path} did not advance (cursor repeated).`);
227
+ cursor = page.next_cursor;
228
+ }
229
+ }
230
+ async #data(method, path, options) {
231
+ return (await this.#send(method, path, options)).data;
232
+ }
233
+ async #send(method, template, raw) {
234
+ if (!methods.includes(method))
235
+ throw new TypeError(`Unsupported method ${method}.`);
236
+ if (/^\/v1\/changelog\/[^/]+\/media\/[^/]+(?:$|[/?#])/.test(template) ||
237
+ /^\/v1\/unlisted\/changelog\/preview\/[^/]+\/media\/[^/]+(?:$|[/?#])/.test(template))
238
+ throw new TypeError('Changelog images require a binary transport, never JSON retries.');
239
+ if (/^\/v1\/testimonials\/[^/]+\/image(?:$|[/?#])/.test(template))
240
+ throw new TypeError('Use downloadTestimonialImage for binary social images.');
241
+ if (/^\/v1\/branding\/previews\/[^/]+(?:$|[/?#])/.test(template))
242
+ throw new TypeError('Use downloadBrandingPreview for private logo bytes.');
243
+ if (/^\/v1\/exports\/[^/]+\/content(?:$|[/?#])/.test(template))
244
+ throw new TypeError('Use downloadExport for binary export content.');
245
+ if (/^\/v1\/public\/(?:branding|changelog)\/media\/[^/]+(?:$|[/?#])/.test(template))
246
+ throw new TypeError('Public media URLs are binary image resources, not JSON SDK operations.');
247
+ const options = (raw ?? {});
248
+ const path = expandPath(template, options.path);
249
+ const url = `${this.#baseUrl}${path}${searchOf(options.query)}`;
250
+ const headers = new Headers({ ...this.#options.headers, ...options.headers });
251
+ headers.set('accept', 'application/json');
252
+ if (this.#options.apiKey)
253
+ headers.set('authorization', `Bearer ${this.#options.apiKey}`);
254
+ const workspace = options.workspace ?? this.#options.workspace;
255
+ if (workspace)
256
+ headers.set('x-workspace', workspace);
257
+ let body;
258
+ if (options.body !== undefined) {
259
+ body = JSON.stringify(options.body);
260
+ headers.set('content-type', 'application/json');
261
+ }
262
+ const idempotencyKey = method === 'post'
263
+ ? (options.idempotencyKey ?? (this.#maxRetries > 0 ? crypto.randomUUID() : null))
264
+ : null;
265
+ if (idempotencyKey)
266
+ headers.set('idempotency-key', idempotencyKey);
267
+ // A 5xx or a lost connection may follow a write that happened: retry only when repeating
268
+ // it is harmless (GET/PUT/DELETE, or a POST the API deduplicates by its Idempotency-Key).
269
+ const repeatable = method !== 'patch' && (method !== 'post' || idempotencyKey !== null);
270
+ const fetcher = this.#options.fetch ?? ((input, init) => fetch(input, init));
271
+ const init = {
272
+ method: method.toUpperCase(),
273
+ headers,
274
+ ...(body !== undefined ? { body } : {}),
275
+ ...(options.signal ? { signal: options.signal } : {}),
276
+ };
277
+ for (let attempt = 0;; attempt++) {
278
+ let response;
279
+ try {
280
+ response = await fetcher(url, init);
281
+ }
282
+ catch (error) {
283
+ if (options.signal?.aborted || !repeatable || attempt >= this.#maxRetries)
284
+ throw error;
285
+ await sleep(backoff(attempt), options.signal);
286
+ continue;
287
+ }
288
+ const data = await bodyOf(response);
289
+ if (response.ok)
290
+ return { data, status: response.status, headers: response.headers };
291
+ const retryAfter = parseRetryAfter(response.headers.get('retry-after'));
292
+ const retryable = response.status === 429 || (retryableStatus.has(response.status) && repeatable);
293
+ const delay = retryAfter === null ? backoff(attempt) : retryAfter * 1000;
294
+ if (retryable && attempt < this.#maxRetries && delay <= this.#maxRetryDelayMs) {
295
+ await sleep(delay, options.signal);
296
+ continue;
297
+ }
298
+ throw new UpVoicedError({
299
+ status: response.status,
300
+ ...errorShape(response.status, response.statusText, data),
301
+ method: method.toUpperCase(),
302
+ path,
303
+ retryAfter,
304
+ idempotencyKey,
305
+ body: data,
306
+ });
307
+ }
308
+ }
309
+ }
@@ -0,0 +1,43 @@
1
+ /** One failed field or rule, as the API reports it in `error.details`. */
2
+ export interface ErrorDetail {
3
+ path: string;
4
+ code: string;
5
+ }
6
+ export interface UpVoicedErrorInit {
7
+ status: number;
8
+ code: string;
9
+ message: string;
10
+ details: readonly ErrorDetail[];
11
+ method: string;
12
+ path: string;
13
+ retryAfter: number | null;
14
+ idempotencyKey: string | null;
15
+ body: unknown;
16
+ }
17
+ /**
18
+ * P2-015 (ADR-0067): every non-2xx answer becomes an UpVoicedError carrying the API's error
19
+ * shape `{ error: { code, message, details? } }` (spec 05). Bodies that are not that shape
20
+ * (a proxy's HTML 502) get `code: "http_<status>"`.
21
+ */
22
+ export declare class UpVoicedError extends Error {
23
+ readonly name = "UpVoicedError";
24
+ /** HTTP status. */
25
+ readonly status: number;
26
+ /** Stable machine-readable code (`insufficient_scope`, `not_found`, `rate_limited`, …). */
27
+ readonly code: string;
28
+ readonly details: readonly ErrorDetail[];
29
+ readonly method: string;
30
+ /** Request path without the query string. */
31
+ readonly path: string;
32
+ /** Seconds from `Retry-After` (429/503), else null. */
33
+ readonly retryAfter: number | null;
34
+ /** The Idempotency-Key the request carried; reuse it to retry the same POST safely. */
35
+ readonly idempotencyKey: string | null;
36
+ /** The parsed (or raw text) response body. */
37
+ readonly body: unknown;
38
+ constructor(init: UpVoicedErrorInit);
39
+ }
40
+ /** Reads `{ error: { code, message, details? } }`, tolerating anything else. */
41
+ export declare function errorShape(status: number, statusText: string, body: unknown): Pick<UpVoicedErrorInit, 'code' | 'message' | 'details'>;
42
+ /** `Retry-After` as seconds: delta-seconds or an HTTP date; null when absent or invalid. */
43
+ export declare function parseRetryAfter(value: string | null, now?: number): number | null;