@upvoiced/sdk 0.1.0 → 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/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
+ }