@upvoiced/sdk 0.1.0 → 1.32.3
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/README.md +26 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.js +100 -0
- package/dist/export-download.d.ts +11 -0
- package/dist/export-download.js +106 -0
- package/dist/generated/schema.d.ts +29482 -5886
- package/dist/generated/schema.js +1 -1
- package/dist/image-download.d.ts +33 -0
- package/dist/image-download.js +132 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,3 +132,29 @@ package version follows UpVoiced releases; each release is regenerated from that
|
|
|
132
132
|
`src/generated/schema.ts` is generated, never edited: after changing API routes run
|
|
133
133
|
`pnpm --filter @upvoiced/sdk generate` (needs a built `@upvoiced/api`). The SDK's tests fail
|
|
134
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.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DownloadedImage, type DownloadedSocialImage } from './image-download.js';
|
|
1
2
|
import type { ApiResponse, HttpMethod, Operation, PageItem, PaginatedPath, PathsWith, RequestArgs, ResponseData } from './types.js';
|
|
2
3
|
/** Production API origin (ADR-0015). Staging: https://api-staging.upvoiced.com */
|
|
3
4
|
export declare const defaultBaseUrl = "https://api.upvoiced.com";
|
|
@@ -37,6 +38,34 @@ export interface ClientOptions {
|
|
|
37
38
|
export declare class UpVoiced {
|
|
38
39
|
#private;
|
|
39
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>;
|
|
40
69
|
/** A client with the same settings acting on another workspace (operator-wide keys). */
|
|
41
70
|
withWorkspace(workspace: string): UpVoiced;
|
|
42
71
|
get<P extends PathsWith<'get'>>(path: P, ...args: RequestArgs<Operation<P, 'get'>>): Promise<ResponseData<Operation<P, "get">>>;
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { downloadImageBytes, downloadSocialImageBytes, } from './image-download.js';
|
|
2
|
+
import { downloadExportBytes } from './export-download.js';
|
|
1
3
|
import { UpVoicedError, errorShape, parseRetryAfter } from './errors.js';
|
|
2
4
|
/** Production API origin (ADR-0015). Staging: https://api-staging.upvoiced.com */
|
|
3
5
|
export const defaultBaseUrl = 'https://api.upvoiced.com';
|
|
@@ -96,6 +98,93 @@ export class UpVoiced {
|
|
|
96
98
|
this.#maxRetries = maxRetries;
|
|
97
99
|
this.#maxRetryDelayMs = maxRetryDelayMs;
|
|
98
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
|
+
}
|
|
99
188
|
/** A client with the same settings acting on another workspace (operator-wide keys). */
|
|
100
189
|
withWorkspace(workspace) {
|
|
101
190
|
return new UpVoiced({ ...this.#options, workspace });
|
|
@@ -144,6 +233,17 @@ export class UpVoiced {
|
|
|
144
233
|
async #send(method, template, raw) {
|
|
145
234
|
if (!methods.includes(method))
|
|
146
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.');
|
|
147
247
|
const options = (raw ?? {});
|
|
148
248
|
const path = expandPath(template, options.path);
|
|
149
249
|
const url = `${this.#baseUrl}${path}${searchOf(options.query)}`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Binary consumer only. Callers bind URL to their configured API and exact resource ID;
|
|
2
|
+
* this utility never derives authority from a response URL or retries a request. */
|
|
3
|
+
export declare function downloadExportBytes(options: {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly headers: HeadersInit;
|
|
6
|
+
readonly bytes: number;
|
|
7
|
+
readonly mime: 'text/csv' | 'application/json' | 'application/zip' | 'image/png' | 'image/jpeg' | 'image/webp' | 'image/svg+xml';
|
|
8
|
+
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
9
|
+
readonly assertCurrent?: () => void;
|
|
10
|
+
readonly signal?: AbortSignal;
|
|
11
|
+
}): Promise<ArrayBuffer>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Binary consumer only. Callers bind URL to their configured API and exact resource ID;
|
|
2
|
+
* this utility never derives authority from a response URL or retries a request. */
|
|
3
|
+
export async function downloadExportBytes(options) {
|
|
4
|
+
const refused = () => new Error('Binary download is unavailable.');
|
|
5
|
+
const bytes = options.bytes, mime = options.mime, url = options.url, headers = new Headers(options.headers), fetcher = options.fetch ?? fetch, assertCurrent = options.assertCurrent, callerSignal = options.signal;
|
|
6
|
+
if (!Number.isSafeInteger(bytes) ||
|
|
7
|
+
bytes < 1 ||
|
|
8
|
+
bytes > 209715200 ||
|
|
9
|
+
![
|
|
10
|
+
'text/csv',
|
|
11
|
+
'application/json',
|
|
12
|
+
'application/zip',
|
|
13
|
+
'image/png',
|
|
14
|
+
'image/jpeg',
|
|
15
|
+
'image/webp',
|
|
16
|
+
'image/svg+xml',
|
|
17
|
+
].includes(mime))
|
|
18
|
+
throw refused();
|
|
19
|
+
const controller = new AbortController(), signal = callerSignal ? AbortSignal.any([controller.signal, callerSignal]) : controller.signal;
|
|
20
|
+
const deadline = performance.now() + 120000;
|
|
21
|
+
let reader, output;
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
controller.abort();
|
|
24
|
+
void reader?.cancel().catch(() => { });
|
|
25
|
+
}, 120000);
|
|
26
|
+
const check = () => {
|
|
27
|
+
if (signal.aborted || performance.now() >= deadline)
|
|
28
|
+
throw refused();
|
|
29
|
+
assertCurrent?.();
|
|
30
|
+
};
|
|
31
|
+
const bounded = (promise) => new Promise((resolve, reject) => {
|
|
32
|
+
const abort = () => {
|
|
33
|
+
reject(refused());
|
|
34
|
+
};
|
|
35
|
+
if (signal.aborted) {
|
|
36
|
+
void promise.catch(() => { });
|
|
37
|
+
reject(refused());
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
41
|
+
void promise.then((value) => {
|
|
42
|
+
signal.removeEventListener('abort', abort);
|
|
43
|
+
resolve(value);
|
|
44
|
+
}, () => {
|
|
45
|
+
signal.removeEventListener('abort', abort);
|
|
46
|
+
reject(refused());
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
check();
|
|
51
|
+
const pending = fetcher(url, {
|
|
52
|
+
method: 'GET',
|
|
53
|
+
headers,
|
|
54
|
+
credentials: 'omit',
|
|
55
|
+
cache: 'no-store',
|
|
56
|
+
redirect: 'error',
|
|
57
|
+
signal,
|
|
58
|
+
});
|
|
59
|
+
void pending.then((response) => {
|
|
60
|
+
if (signal.aborted)
|
|
61
|
+
void response.body?.cancel().catch(() => { });
|
|
62
|
+
}, () => { });
|
|
63
|
+
const response = await bounded(pending);
|
|
64
|
+
reader = response.body?.getReader();
|
|
65
|
+
check();
|
|
66
|
+
if (response.status !== 200 ||
|
|
67
|
+
response.headers.get('content-type') !== mime ||
|
|
68
|
+
response.headers.get('content-length') !== String(bytes) ||
|
|
69
|
+
!reader)
|
|
70
|
+
throw refused();
|
|
71
|
+
output = new Uint8Array(bytes);
|
|
72
|
+
let count = 0;
|
|
73
|
+
for (;;) {
|
|
74
|
+
check();
|
|
75
|
+
const next = await bounded(reader.read());
|
|
76
|
+
check();
|
|
77
|
+
if (next.done)
|
|
78
|
+
break;
|
|
79
|
+
const raw = next.value;
|
|
80
|
+
// Native byte streams can cross a browser/Worker realm. Require a real typed
|
|
81
|
+
// byte view rather than relying on this realm's constructor identity.
|
|
82
|
+
if (!ArrayBuffer.isView(raw) ||
|
|
83
|
+
Object.prototype.toString.call(raw) !== '[object Uint8Array]' ||
|
|
84
|
+
count + raw.byteLength > bytes)
|
|
85
|
+
throw refused();
|
|
86
|
+
const chunk = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
87
|
+
output.set(chunk, count);
|
|
88
|
+
count += chunk.byteLength;
|
|
89
|
+
}
|
|
90
|
+
if (count !== bytes)
|
|
91
|
+
throw refused();
|
|
92
|
+
check();
|
|
93
|
+
const result = output.buffer;
|
|
94
|
+
output = undefined;
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw refused();
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
controller.abort();
|
|
103
|
+
void reader?.cancel().catch(() => { });
|
|
104
|
+
output?.fill(0);
|
|
105
|
+
}
|
|
106
|
+
}
|