@usequeek/app-sdk 0.6.1 → 0.7.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 +1 -1
- package/README.md +339 -330
- package/dist/app-auth.d.ts +15 -17
- package/dist/app-auth.js +20 -23
- package/dist/auth-fetch.d.ts +14 -5
- package/dist/auth-fetch.js +15 -6
- package/dist/background.d.ts +10 -2
- package/dist/background.js +9 -1
- package/dist/browser.d.ts +2 -3
- package/dist/browser.js +2 -3
- package/dist/client.d.ts +138 -16
- package/dist/client.js +200 -43
- package/dist/collected.d.ts +13 -15
- package/dist/collected.js +4 -5
- package/dist/crypto.d.ts +11 -1
- package/dist/crypto.js +25 -10
- package/dist/delivery.d.ts +22 -9
- package/dist/delivery.js +26 -21
- package/dist/frame.d.ts +11 -12
- package/dist/frame.js +11 -12
- package/dist/handoff.d.ts +35 -39
- package/dist/handoff.js +9 -10
- package/dist/hono.d.ts +3 -4
- package/dist/hono.js +3 -4
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/install-handlers.d.ts +28 -9
- package/dist/install-handlers.js +48 -18
- package/dist/logger.d.ts +7 -1
- package/dist/logger.js +8 -2
- package/dist/merchant-schema.d.ts +15678 -6236
- package/dist/merchant-schema.js +0 -1
- package/dist/proxy.d.ts +21 -24
- package/dist/proxy.js +20 -22
- package/dist/react.d.ts +10 -2
- package/dist/react.js +11 -3
- package/dist/resync.d.ts +15 -5
- package/dist/resync.js +22 -20
- package/dist/scopes.d.ts +36 -27
- package/dist/scopes.js +37 -40
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/session.d.ts +35 -17
- package/dist/session.js +32 -15
- package/dist/signatures.d.ts +31 -5
- package/dist/signatures.js +36 -9
- package/dist/store.d.ts +26 -26
- package/dist/store.js +16 -20
- package/dist/theme.d.ts +1 -2
- package/dist/theme.js +1 -2
- package/dist/tokens.d.ts +18 -10
- package/dist/tokens.js +32 -57
- package/dist/webhooks.d.ts +30 -6
- package/dist/webhooks.js +27 -10
- package/package.json +21 -4
- package/dist/app-auth.d.ts.map +0 -1
- package/dist/app-auth.js.map +0 -1
- package/dist/auth-fetch.d.ts.map +0 -1
- package/dist/auth-fetch.js.map +0 -1
- package/dist/background.d.ts.map +0 -1
- package/dist/background.js.map +0 -1
- package/dist/browser.d.ts.map +0 -1
- package/dist/browser.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/collected.d.ts.map +0 -1
- package/dist/collected.js.map +0 -1
- package/dist/crypto.d.ts.map +0 -1
- package/dist/crypto.js.map +0 -1
- package/dist/delivery.d.ts.map +0 -1
- package/dist/delivery.js.map +0 -1
- package/dist/frame.d.ts.map +0 -1
- package/dist/frame.js.map +0 -1
- package/dist/handoff.d.ts.map +0 -1
- package/dist/handoff.js.map +0 -1
- package/dist/hono.d.ts.map +0 -1
- package/dist/hono.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/install-handlers.d.ts.map +0 -1
- package/dist/install-handlers.js.map +0 -1
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/merchant-schema.d.ts.map +0 -1
- package/dist/merchant-schema.js.map +0 -1
- package/dist/proxy.d.ts.map +0 -1
- package/dist/proxy.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/resync.d.ts.map +0 -1
- package/dist/resync.js.map +0 -1
- package/dist/scopes.d.ts.map +0 -1
- package/dist/scopes.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js.map +0 -1
- package/dist/signatures.d.ts.map +0 -1
- package/dist/signatures.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/dist/theme.d.ts.map +0 -1
- package/dist/theme.js.map +0 -1
- package/dist/tokens.d.ts.map +0 -1
- package/dist/tokens.js.map +0 -1
- package/dist/webhooks.d.ts.map +0 -1
- package/dist/webhooks.js.map +0 -1
package/dist/client.js
CHANGED
|
@@ -24,7 +24,7 @@ function parseHostList(raw, varName) {
|
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* Extra allowed `apiBase` hosts for test
|
|
27
|
+
* Extra allowed `apiBase` hosts for test and local setups, from
|
|
28
28
|
* `QUEEK_API_HOSTS` (comma-separated bare hostnames). Parsed strictly: any
|
|
29
29
|
* entry that is not a bare hostname throws — nothing is silently skipped.
|
|
30
30
|
*/
|
|
@@ -35,7 +35,7 @@ export function apiHostsFromEnv(env = process.env) {
|
|
|
35
35
|
return parseHostList(raw, ENV_HOSTS_VAR);
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
* DEV-ONLY extra allowed hosts (e.g. an HTTPS tunnel to a local
|
|
38
|
+
* DEV-ONLY extra allowed hosts (e.g. an HTTPS tunnel to a local Queek API),
|
|
39
39
|
* from `QUEEK_DEV_API_HOSTS`. REFUSED when NODE_ENV=production: a dev
|
|
40
40
|
* tunnel host must never be allowlisted in production, even by accident.
|
|
41
41
|
* Throws at client construction, before any fetch.
|
|
@@ -88,11 +88,16 @@ export function resolveApiBase(raw, allowedApiHosts = []) {
|
|
|
88
88
|
const merchantPath = path.endsWith(MERCHANT_API_PATH) ? path : `${path}${MERCHANT_API_PATH}`;
|
|
89
89
|
return `https://${host}${url.port !== "" ? `:${url.port}` : ""}${merchantPath}`;
|
|
90
90
|
}
|
|
91
|
+
/** Default time a single HTTP attempt may take before it fails with `request_timeout`. */
|
|
92
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
93
|
+
/** Default ceiling for honouring a 429 `Retry-After` inside `requestWithRetry()`. */
|
|
94
|
+
export const DEFAULT_MAX_RETRY_AFTER_MS = 30_000;
|
|
91
95
|
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
92
96
|
/** True for HTTP methods that carry an `Idempotency-Key` (POST/PUT/PATCH/DELETE). */
|
|
93
97
|
export function isWriteMethod(method) {
|
|
94
98
|
return WRITE_METHODS.has(method.toUpperCase());
|
|
95
99
|
}
|
|
100
|
+
/** A fresh `Idempotency-Key` value (a random UUID). */
|
|
96
101
|
export function newIdempotencyKey() {
|
|
97
102
|
return randomUUID();
|
|
98
103
|
}
|
|
@@ -110,6 +115,28 @@ function parseRetryAfterMs(value) {
|
|
|
110
115
|
}
|
|
111
116
|
return null;
|
|
112
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* The one error every Queek HTTP call throws. Switch on `code`, never on
|
|
120
|
+
* `message`.
|
|
121
|
+
*
|
|
122
|
+
* Codes that come from Queek are the API's own (`validation_failed`,
|
|
123
|
+
* `insufficient_scope`, `too_many_requests`, ...). Codes the SDK raises when
|
|
124
|
+
* no usable HTTP answer arrived (`status` is 0 for the first three):
|
|
125
|
+
* `network_error`, `request_timeout`, `request_aborted`, and
|
|
126
|
+
* `invalid_response` (an HTTP success whose body was not JSON).
|
|
127
|
+
*
|
|
128
|
+
* `requestId` matches the `X-Request-Id` response header: quote it to Queek
|
|
129
|
+
* support.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* try {
|
|
133
|
+
* await client.request("POST", "/orders", { body });
|
|
134
|
+
* } catch (error) {
|
|
135
|
+
* if (error instanceof QueekApiError && error.code === "validation_failed") {
|
|
136
|
+
* console.error(error.field, error.errors, error.requestId);
|
|
137
|
+
* }
|
|
138
|
+
* }
|
|
139
|
+
*/
|
|
113
140
|
export class QueekApiError extends Error {
|
|
114
141
|
status;
|
|
115
142
|
code;
|
|
@@ -120,7 +147,7 @@ export class QueekApiError extends Error {
|
|
|
120
147
|
/** Set on 429s (and only there): how long to wait before retrying. */
|
|
121
148
|
retryAfterMs;
|
|
122
149
|
constructor(details) {
|
|
123
|
-
super(details.message);
|
|
150
|
+
super(details.message, details.cause === undefined ? undefined : { cause: details.cause });
|
|
124
151
|
this.name = "QueekApiError";
|
|
125
152
|
this.status = details.status;
|
|
126
153
|
this.code = details.code;
|
|
@@ -136,6 +163,62 @@ export class QueekApiError extends Error {
|
|
|
136
163
|
get isAuthFailure() {
|
|
137
164
|
return this.status === 401 || this.status === 403;
|
|
138
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* True when sending the same call again can succeed without any change:
|
|
168
|
+
* rate limits, gateway failures (502, 503, 504), timeouts and network
|
|
169
|
+
* errors. `requestWithRetry()` retries exactly these. A caller abort is
|
|
170
|
+
* never retryable.
|
|
171
|
+
*/
|
|
172
|
+
get isRetryable() {
|
|
173
|
+
return (this.status === 429 ||
|
|
174
|
+
this.status === 502 ||
|
|
175
|
+
this.status === 503 ||
|
|
176
|
+
this.status === 504 ||
|
|
177
|
+
this.code === "network_error" ||
|
|
178
|
+
this.code === "request_timeout");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The error for a call that never produced an HTTP answer. A caller abort is
|
|
183
|
+
* `request_aborted`, the client timeout is `request_timeout`, everything else
|
|
184
|
+
* (DNS, refused or reset connection, TLS) is `network_error`. The original
|
|
185
|
+
* error stays on `cause`.
|
|
186
|
+
*/
|
|
187
|
+
export function transportError(cause, signal) {
|
|
188
|
+
const name = cause instanceof Error ? cause.name : "";
|
|
189
|
+
const reason = cause instanceof Error && cause.message !== "" ? ` (${cause.message})` : "";
|
|
190
|
+
if (name === "TimeoutError") {
|
|
191
|
+
return new QueekApiError({
|
|
192
|
+
status: 0,
|
|
193
|
+
code: "request_timeout",
|
|
194
|
+
message: "Queek did not answer before the request timeout. Retry, raise `timeoutMs`, or use requestWithRetry().",
|
|
195
|
+
cause,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (signal?.aborted === true) {
|
|
199
|
+
return new QueekApiError({
|
|
200
|
+
status: 0,
|
|
201
|
+
code: "request_aborted",
|
|
202
|
+
message: "The request was aborted by its AbortSignal before Queek answered.",
|
|
203
|
+
cause,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return new QueekApiError({
|
|
207
|
+
status: 0,
|
|
208
|
+
code: "network_error",
|
|
209
|
+
message: `Could not reach Queek${reason}. Check the network connection and the api_base host, then retry; requestWithRetry() does this for you.`,
|
|
210
|
+
cause,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Combine a caller signal with the per-attempt timeout. Returns the caller
|
|
215
|
+
* signal untouched when `timeoutMs` is 0 or negative.
|
|
216
|
+
*/
|
|
217
|
+
export function requestSignal(signal, timeoutMs) {
|
|
218
|
+
if (!(timeoutMs > 0))
|
|
219
|
+
return signal;
|
|
220
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
221
|
+
return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
139
222
|
}
|
|
140
223
|
/**
|
|
141
224
|
* Build a `QueekApiError` from a status + parsed body + headers. Shared
|
|
@@ -153,7 +236,8 @@ export function queekApiErrorFromResponse(status, body, headers) {
|
|
|
153
236
|
return undefined;
|
|
154
237
|
};
|
|
155
238
|
const code = firstString(envelope.code, record.error_code) ?? (status === 429 ? "too_many_requests" : "server_error");
|
|
156
|
-
const message = firstString(envelope.message, record.message) ??
|
|
239
|
+
const message = firstString(envelope.message, record.message) ??
|
|
240
|
+
`Queek answered HTTP ${status} without an error message. Retry, and quote the request id if it persists.`;
|
|
157
241
|
const details = {
|
|
158
242
|
status,
|
|
159
243
|
code,
|
|
@@ -181,14 +265,56 @@ export function queekApiErrorFromResponse(status, body, headers) {
|
|
|
181
265
|
function defaultSleep(ms) {
|
|
182
266
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
183
267
|
}
|
|
184
|
-
|
|
185
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Milliseconds to wait before the next attempt, or `null` when the error
|
|
270
|
+
* must be thrown instead of retried (not retryable, or a `Retry-After`
|
|
271
|
+
* longer than `maxRetryAfterMs`). Backoff is exponential with equal jitter
|
|
272
|
+
* (half fixed, half random) so many clients do not retry in lockstep.
|
|
273
|
+
*/
|
|
274
|
+
function retryDelayMs(error, attempt, options) {
|
|
275
|
+
if (!error.isRetryable)
|
|
276
|
+
return null;
|
|
277
|
+
if (error.isRateLimited && error.retryAfterMs !== undefined) {
|
|
278
|
+
return error.retryAfterMs <= (options.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS)
|
|
279
|
+
? error.retryAfterMs
|
|
280
|
+
: null;
|
|
281
|
+
}
|
|
282
|
+
const ceiling = Math.min(options.maxDelayMs ?? 5000, (options.baseDelayMs ?? 250) * 2 ** attempt);
|
|
283
|
+
const random = options.random ?? Math.random;
|
|
284
|
+
return Math.floor(ceiling / 2 + (random() * ceiling) / 2);
|
|
186
285
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
286
|
+
/**
|
|
287
|
+
* The retry loop both clients share. `run` receives the idempotency key to
|
|
288
|
+
* send (generated ONCE for writes when the caller gave none), so every
|
|
289
|
+
* attempt of one write carries the same key.
|
|
290
|
+
*/
|
|
291
|
+
export async function runWithRetry(method, options, run) {
|
|
292
|
+
const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
|
|
293
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
294
|
+
const idempotencyKey = options.idempotencyKey ?? (isWriteMethod(method) ? newIdempotencyKey() : undefined);
|
|
295
|
+
for (let attempt = 0;; attempt++) {
|
|
296
|
+
try {
|
|
297
|
+
return await run(idempotencyKey);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (!(error instanceof QueekApiError) || attempt >= maxAttempts - 1)
|
|
301
|
+
throw error;
|
|
302
|
+
const delay = retryDelayMs(error, attempt, options);
|
|
303
|
+
if (delay === null)
|
|
304
|
+
throw error;
|
|
305
|
+
await sleep(delay);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
191
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Create a client for the Merchant API with one static installation key.
|
|
311
|
+
* Most apps want `createInstallationClient()` instead, which mints and
|
|
312
|
+
* refreshes the key for you.
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* const client = createQueekClient({ apiBase: install.api_base, apiKey: process.env.QUEEK_KEY ?? "" });
|
|
316
|
+
* const store = await client.getStore();
|
|
317
|
+
*/
|
|
192
318
|
export function createQueekClient(clientOptions) {
|
|
193
319
|
const base = resolveApiBase(clientOptions.apiBase, clientOptions.allowedApiHosts ?? []);
|
|
194
320
|
const fetchImpl = clientOptions.fetchImpl ?? fetch;
|
|
@@ -215,46 +341,23 @@ export function createQueekClient(clientOptions) {
|
|
|
215
341
|
if (isWriteMethod(upper)) {
|
|
216
342
|
headers.set("Idempotency-Key", options.idempotencyKey ?? newIdempotencyKey());
|
|
217
343
|
}
|
|
344
|
+
const signal = requestSignal(options.signal, options.timeoutMs ?? clientOptions.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
218
345
|
let response;
|
|
346
|
+
let text;
|
|
219
347
|
try {
|
|
220
|
-
response = await fetchImpl(url.toString(), { method: upper, headers, body, signal
|
|
348
|
+
response = await fetchImpl(url.toString(), { method: upper, headers, body, signal });
|
|
349
|
+
text = await response.text();
|
|
221
350
|
}
|
|
222
351
|
catch (cause) {
|
|
223
|
-
throw
|
|
224
|
-
status: 0,
|
|
225
|
-
code: "network_error",
|
|
226
|
-
message: cause instanceof Error ? `Could not reach Queek: ${cause.message}` : "Could not reach Queek.",
|
|
227
|
-
});
|
|
352
|
+
throw transportError(cause, options.signal);
|
|
228
353
|
}
|
|
229
|
-
const text = await response.text();
|
|
230
|
-
const parsed = text === "" ? null : tryParseJson(text);
|
|
231
354
|
if (!response.ok) {
|
|
232
|
-
throw queekApiErrorFromResponse(response.status,
|
|
355
|
+
throw queekApiErrorFromResponse(response.status, text === "" ? null : tryParseJson(text), response.headers);
|
|
233
356
|
}
|
|
234
|
-
return
|
|
357
|
+
return parseSuccessBody(response, text);
|
|
235
358
|
}
|
|
236
359
|
async function requestWithRetry(method, path, options = {}) {
|
|
237
|
-
|
|
238
|
-
const baseDelayMs = options.baseDelayMs ?? 250;
|
|
239
|
-
const maxDelayMs = options.maxDelayMs ?? 5000;
|
|
240
|
-
const sleep = options.sleep ?? defaultSleep;
|
|
241
|
-
// One key for every attempt: Queek replays same-key+same-body writes
|
|
242
|
-
// instead of duplicating them, so retrying a write is safe.
|
|
243
|
-
const idempotencyKey = options.idempotencyKey ?? (isWriteMethod(method) ? newIdempotencyKey() : undefined);
|
|
244
|
-
let lastError;
|
|
245
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
246
|
-
try {
|
|
247
|
-
return await request(method, path, { ...options, idempotencyKey });
|
|
248
|
-
}
|
|
249
|
-
catch (error) {
|
|
250
|
-
lastError = error;
|
|
251
|
-
const retryable = error instanceof QueekApiError && isRetryable(error);
|
|
252
|
-
if (!retryable || attempt === maxAttempts - 1)
|
|
253
|
-
throw error;
|
|
254
|
-
await sleep(retryDelayMs(error, attempt, baseDelayMs, maxDelayMs));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
throw lastError;
|
|
360
|
+
return runWithRetry(method, options, (idempotencyKey) => request(method, path, { ...options, idempotencyKey }));
|
|
258
361
|
}
|
|
259
362
|
return {
|
|
260
363
|
getStore: (signal) => request("GET", "/store", { signal }),
|
|
@@ -262,7 +365,11 @@ export function createQueekClient(clientOptions) {
|
|
|
262
365
|
requestWithRetry,
|
|
263
366
|
};
|
|
264
367
|
}
|
|
265
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Parse a non-JSON-safe error body: JSON when it parses, otherwise the first
|
|
370
|
+
* 500 characters as the error `message`.
|
|
371
|
+
*/
|
|
372
|
+
export function tryParseJson(text) {
|
|
266
373
|
try {
|
|
267
374
|
return JSON.parse(text);
|
|
268
375
|
}
|
|
@@ -270,4 +377,54 @@ function tryParseJson(text) {
|
|
|
270
377
|
return { message: text.slice(0, 500) };
|
|
271
378
|
}
|
|
272
379
|
}
|
|
273
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Parse an HTTP success body. An empty body is `null`; a body that is not
|
|
382
|
+
* JSON is an `invalid_response` error, never a silently mistyped value.
|
|
383
|
+
*/
|
|
384
|
+
export function parseSuccessBody(response, text) {
|
|
385
|
+
if (text === "")
|
|
386
|
+
return null;
|
|
387
|
+
try {
|
|
388
|
+
return JSON.parse(text);
|
|
389
|
+
}
|
|
390
|
+
catch (cause) {
|
|
391
|
+
throw new QueekApiError({
|
|
392
|
+
status: response.status,
|
|
393
|
+
code: "invalid_response",
|
|
394
|
+
message: `Queek answered HTTP ${response.status} with a body that is not JSON. Retry; if it persists, quote the request id to Queek support.`,
|
|
395
|
+
requestId: response.headers.get("x-request-id") ?? undefined,
|
|
396
|
+
cause,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Iterate every row of a cursor-paginated list (`data`, `has_more`,
|
|
402
|
+
* `next_cursor`, driven by `starting_after`), fetching one page at a time
|
|
403
|
+
* with `requestWithRetry()`. Stops when `has_more` is false.
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* for await (const order of paginate<{ p_id: string }>(client, "/orders", { limit: 50 })) {
|
|
407
|
+
* console.log(order.p_id);
|
|
408
|
+
* }
|
|
409
|
+
*/
|
|
410
|
+
export async function* paginate(client, path, options = {}) {
|
|
411
|
+
let cursor;
|
|
412
|
+
while (true) {
|
|
413
|
+
const page = await client.requestWithRetry("GET", path, {
|
|
414
|
+
...options.retry,
|
|
415
|
+
query: { ...options.query, limit: options.limit, starting_after: cursor },
|
|
416
|
+
signal: options.signal,
|
|
417
|
+
});
|
|
418
|
+
yield* page.data;
|
|
419
|
+
if (!page.has_more)
|
|
420
|
+
return;
|
|
421
|
+
if (typeof page.next_cursor !== "string" || page.next_cursor === "" || page.next_cursor === cursor) {
|
|
422
|
+
throw new QueekApiError({
|
|
423
|
+
status: 200,
|
|
424
|
+
code: "invalid_response",
|
|
425
|
+
message: `Queek reported more rows for ${path} but sent no new next_cursor, so paging stopped. Retry; if it persists, quote the request id to Queek support.`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
cursor = page.next_cursor;
|
|
429
|
+
}
|
|
430
|
+
}
|
package/dist/collected.d.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { QueekClient } from "./client.js";
|
|
2
2
|
import type { components } from "./merchant-schema.js";
|
|
3
3
|
/**
|
|
4
|
-
* Installation-bound app writes
|
|
4
|
+
* Installation-bound app writes: the setup notice, merchant alerts,
|
|
5
5
|
* collected definitions and record submits. Every helper takes a
|
|
6
|
-
* `QueekClient` built from the installation's own key —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `QueekClient` built from the installation's own key — Queek resolves
|
|
7
|
+
* the installation from that key, never from the body — and returns the
|
|
8
|
+
* decoded `data` payload of the success envelope.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `components` schemas from the same backend's merchant export.
|
|
10
|
+
* Request shapes below are the generated `components` schemas of the
|
|
11
|
+
* Merchant API spec (https://api.usequeek.com/docs/merchant.json).
|
|
13
12
|
*/
|
|
14
13
|
type Schemas = components["schemas"];
|
|
15
14
|
export interface SuccessEnvelope<T> {
|
|
@@ -66,9 +65,9 @@ export interface CollectedDefinitionUpdate {
|
|
|
66
65
|
}
|
|
67
66
|
/**
|
|
68
67
|
* A collected definition as the Merchant API returns it: the generated
|
|
69
|
-
* MetaobjectDefinitionResource
|
|
70
|
-
*
|
|
71
|
-
*
|
|
68
|
+
* MetaobjectDefinitionResource with the create endpoint's field
|
|
69
|
+
* vocabulary for `fields` (the generated `unknown[]` carries no field
|
|
70
|
+
* shape).
|
|
72
71
|
*/
|
|
73
72
|
export type CollectedDefinition = Omit<Schemas["MetaobjectDefinitionResource"], "fields"> & {
|
|
74
73
|
fields: CollectedField[];
|
|
@@ -88,10 +87,10 @@ export declare const collectedDefinitions: {
|
|
|
88
87
|
/**
|
|
89
88
|
* POST records — one record under the installation's own type.
|
|
90
89
|
*
|
|
91
|
-
* `values` is a `{field_key: value}` object (
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
90
|
+
* `values` is a `{field_key: value}` object (an object with
|
|
91
|
+
* additionalProperties in the spec; Queek validates the pairs). A fresh
|
|
92
|
+
* Idempotency-Key is generated per call unless the caller passes one — no
|
|
93
|
+
* two submits ever share a key.
|
|
95
94
|
*/
|
|
96
95
|
export declare function createRecord(client: QueekClient, submit: {
|
|
97
96
|
definition: string;
|
|
@@ -100,4 +99,3 @@ export declare function createRecord(client: QueekClient, submit: {
|
|
|
100
99
|
idempotencyKey?: string;
|
|
101
100
|
}): Promise<unknown>;
|
|
102
101
|
export {};
|
|
103
|
-
//# sourceMappingURL=collected.d.ts.map
|
package/dist/collected.js
CHANGED
|
@@ -44,10 +44,10 @@ export const collectedDefinitions = {
|
|
|
44
44
|
/**
|
|
45
45
|
* POST records — one record under the installation's own type.
|
|
46
46
|
*
|
|
47
|
-
* `values` is a `{field_key: value}` object (
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* `values` is a `{field_key: value}` object (an object with
|
|
48
|
+
* additionalProperties in the spec; Queek validates the pairs). A fresh
|
|
49
|
+
* Idempotency-Key is generated per call unless the caller passes one — no
|
|
50
|
+
* two submits ever share a key.
|
|
51
51
|
*/
|
|
52
52
|
export async function createRecord(client, submit, options = {}) {
|
|
53
53
|
const response = await client.request("POST", "/records", {
|
|
@@ -56,4 +56,3 @@ export async function createRecord(client, submit, options = {}) {
|
|
|
56
56
|
});
|
|
57
57
|
return response.data;
|
|
58
58
|
}
|
|
59
|
-
//# sourceMappingURL=collected.js.map
|
package/dist/crypto.d.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse `APP_ENCRYPTION_KEY` (base64 or hex of 32 bytes, or a raw 32-character
|
|
3
|
+
* string) into the AES-256 key. Prefer random bytes: a raw string key is only
|
|
4
|
+
* as strong as the text you chose. Throws with a fix when the value is
|
|
5
|
+
* missing or the wrong length.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const key = parseStoreKey(process.env.APP_ENCRYPTION_KEY);
|
|
9
|
+
*/
|
|
1
10
|
export declare function parseStoreKey(raw: string | undefined): Buffer;
|
|
2
11
|
/** Non-secret fingerprint for logs (first 4 bytes, hex). Reveals nothing usable. */
|
|
3
12
|
export declare function storeKeyFingerprint(key: Buffer): string;
|
|
13
|
+
/** Encrypt `plaintext` with AES-256-GCM into a `v1.<iv>.<ciphertext+tag>` envelope. */
|
|
4
14
|
export declare function encryptSecret(plaintext: string, key: Buffer): string;
|
|
15
|
+
/** Decrypt an envelope made by {@link encryptSecret}. Throws a plain `Error` (with `cause`) on a wrong key or altered value. */
|
|
5
16
|
export declare function decryptSecret(envelope: string, key: Buffer): string;
|
|
6
|
-
//# sourceMappingURL=crypto.d.ts.map
|
package/dist/crypto.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
2
|
/**
|
|
3
|
-
* AES-256-GCM envelope encryption for secrets at rest (installation
|
|
4
|
-
* and
|
|
3
|
+
* AES-256-GCM envelope encryption for secrets at rest (installation tokens,
|
|
4
|
+
* webhook/proxy/embed secrets and settings). One random 96-bit IV per value; the stored string is
|
|
5
5
|
* `v1.<base64 iv>.<base64 ciphertext+tag>`.
|
|
6
6
|
*
|
|
7
7
|
* The 32-byte data key comes from the environment (`APP_ENCRYPTION_KEY`, base64
|
|
@@ -11,9 +11,18 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
|
11
11
|
*/
|
|
12
12
|
const ENVELOPE_VERSION = "v1";
|
|
13
13
|
const IV_BYTES = 12;
|
|
14
|
+
/**
|
|
15
|
+
* Parse `APP_ENCRYPTION_KEY` (base64 or hex of 32 bytes, or a raw 32-character
|
|
16
|
+
* string) into the AES-256 key. Prefer random bytes: a raw string key is only
|
|
17
|
+
* as strong as the text you chose. Throws with a fix when the value is
|
|
18
|
+
* missing or the wrong length.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const key = parseStoreKey(process.env.APP_ENCRYPTION_KEY);
|
|
22
|
+
*/
|
|
14
23
|
export function parseStoreKey(raw) {
|
|
15
24
|
if (!raw || raw.trim() === "") {
|
|
16
|
-
throw new Error("Missing store encryption key: set APP_ENCRYPTION_KEY to base64 or hex of 32 random bytes.");
|
|
25
|
+
throw new Error("Missing store encryption key: set APP_ENCRYPTION_KEY to base64 or hex of 32 random bytes (`queek app dev` mints one into .queek/.env.local on first run; otherwise generate with node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64'))\" and keep it in your secrets manager — key + DB loss = working data lost).");
|
|
17
26
|
}
|
|
18
27
|
const value = raw.trim();
|
|
19
28
|
const candidates = [];
|
|
@@ -38,7 +47,7 @@ export function parseStoreKey(raw) {
|
|
|
38
47
|
candidates.push(Buffer.from(value, "utf8"));
|
|
39
48
|
const key = candidates.find((candidate) => candidate.length === 32);
|
|
40
49
|
if (!key) {
|
|
41
|
-
throw new Error("Invalid APP_ENCRYPTION_KEY: must decode to exactly 32 bytes (base64 or hex).");
|
|
50
|
+
throw new Error("Invalid APP_ENCRYPTION_KEY: it must decode to exactly 32 bytes (base64 or hex). Generate one with `node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64'))\"`.");
|
|
42
51
|
}
|
|
43
52
|
return key;
|
|
44
53
|
}
|
|
@@ -46,31 +55,37 @@ export function parseStoreKey(raw) {
|
|
|
46
55
|
export function storeKeyFingerprint(key) {
|
|
47
56
|
return key.subarray(0, 4).toString("hex");
|
|
48
57
|
}
|
|
58
|
+
/** Encrypt `plaintext` with AES-256-GCM into a `v1.<iv>.<ciphertext+tag>` envelope. */
|
|
49
59
|
export function encryptSecret(plaintext, key) {
|
|
50
60
|
if (key.length !== 32)
|
|
51
|
-
throw new Error(
|
|
61
|
+
throw new Error(`Encryption key must be exactly 32 bytes, got ${key.length}. Build it with parseStoreKey().`);
|
|
52
62
|
const iv = randomBytes(IV_BYTES);
|
|
53
63
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
54
64
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
55
65
|
const tag = cipher.getAuthTag();
|
|
56
66
|
return [ENVELOPE_VERSION, iv.toString("base64"), Buffer.concat([ciphertext, tag]).toString("base64")].join(".");
|
|
57
67
|
}
|
|
68
|
+
/** Decrypt an envelope made by {@link encryptSecret}. Throws a plain `Error` (with `cause`) on a wrong key or altered value. */
|
|
58
69
|
export function decryptSecret(envelope, key) {
|
|
59
70
|
if (key.length !== 32)
|
|
60
|
-
throw new Error(
|
|
71
|
+
throw new Error(`Encryption key must be exactly 32 bytes, got ${key.length}. Build it with parseStoreKey().`);
|
|
61
72
|
const parts = envelope.split(".");
|
|
62
73
|
if (parts.length !== 3 || parts[0] !== ENVELOPE_VERSION) {
|
|
63
|
-
throw new Error("Unrecognized secret envelope
|
|
74
|
+
throw new Error("Unrecognized secret envelope: expected `v1.<iv>.<ciphertext>`. The stored value was not written by this SDK or was truncated.");
|
|
64
75
|
}
|
|
65
76
|
const iv = Buffer.from(parts[1], "base64");
|
|
66
77
|
const raw = Buffer.from(parts[2], "base64");
|
|
67
78
|
if (iv.length !== IV_BYTES || raw.length < 17) {
|
|
68
|
-
throw new Error("Corrupt secret envelope.");
|
|
79
|
+
throw new Error("Corrupt secret envelope: the IV or ciphertext has the wrong length. Restore the row from a backup.");
|
|
69
80
|
}
|
|
70
81
|
const tag = raw.subarray(raw.length - 16);
|
|
71
82
|
const ciphertext = raw.subarray(0, raw.length - 16);
|
|
72
83
|
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
73
84
|
decipher.setAuthTag(tag);
|
|
74
|
-
|
|
85
|
+
try {
|
|
86
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
87
|
+
}
|
|
88
|
+
catch (cause) {
|
|
89
|
+
throw new Error("Could not decrypt a stored secret: it was written with a different APP_ENCRYPTION_KEY, or the value was changed. Restore the original key; without it the secret cannot be recovered.", { cause });
|
|
90
|
+
}
|
|
75
91
|
}
|
|
76
|
-
//# sourceMappingURL=crypto.js.map
|
package/dist/delivery.d.ts
CHANGED
|
@@ -9,14 +9,10 @@
|
|
|
9
9
|
* standard onto this core; `@usequeek/app-sdk/hono` adapts Hono onto
|
|
10
10
|
* layer 2.
|
|
11
11
|
*
|
|
12
|
-
* The raw-body rule (
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* body of the request causes the verification to fail"): `rawBody` must be
|
|
17
|
-
* the exact bytes Queek signed. Never pass a parsed-then-restringified
|
|
18
|
-
* body — JSON re-serialization changes bytes (spacing, key order) and the
|
|
19
|
-
* signature will not verify.
|
|
12
|
+
* The raw-body rule: `rawBody` must be the exact bytes Queek signed (the
|
|
13
|
+
* Standard Webhooks signature covers `{id}.{timestamp}.{body}`). Never pass
|
|
14
|
+
* a parsed-then-restringified body — JSON re-serialization changes bytes
|
|
15
|
+
* (spacing, key order) and the signature will not verify.
|
|
20
16
|
*/
|
|
21
17
|
/** Headers as any framework holds them: a `Headers` instance, or a plain object (Express-style: lowercased keys, array values allowed). */
|
|
22
18
|
export type CoreHeaders = Record<string, string | string[] | undefined> | Headers;
|
|
@@ -43,6 +39,23 @@ export interface DeliveryResult {
|
|
|
43
39
|
export declare function readHeader(headers: CoreHeaders, name: string): string | undefined;
|
|
44
40
|
/** Decode untouched body bytes to the exact string Queek signed. */
|
|
45
41
|
export declare function decodeBody(rawBody: Uint8Array | Buffer | string): string;
|
|
42
|
+
/** Where a callback failed, passed to `onError`. */
|
|
43
|
+
export interface DeliveryFailure {
|
|
44
|
+
/** `install`, `uninstall`, `settings`, `scopes_update` or `webhook`. */
|
|
45
|
+
stage: string;
|
|
46
|
+
/** The `webhook-id` header of the delivery that failed. */
|
|
47
|
+
eventId: string;
|
|
48
|
+
/** The topic, for webhook deliveries. */
|
|
49
|
+
topic?: string;
|
|
50
|
+
}
|
|
51
|
+
/** Receives the error a callback threw. The delivery still answers 500 so Queek retries. */
|
|
52
|
+
export type DeliveryErrorHandler = (error: unknown, failure: DeliveryFailure) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Surface the error a callback threw, so a 500 is never a mystery. Calls the
|
|
55
|
+
* app's `onError` when given, otherwise logs the error name and message
|
|
56
|
+
* (secrets redacted) to stderr. A throwing `onError` cannot change the
|
|
57
|
+
* response.
|
|
58
|
+
*/
|
|
59
|
+
export declare function reportDeliveryFailure(onError: DeliveryErrorHandler | undefined, error: unknown, failure: DeliveryFailure): void;
|
|
46
60
|
/** Build the layer-2 `Response` from a layer-1 result. */
|
|
47
61
|
export declare function toResponse(result: DeliveryResult): Response;
|
|
48
|
-
//# sourceMappingURL=delivery.d.ts.map
|
package/dist/delivery.js
CHANGED
|
@@ -1,23 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Shared plumbing for the framework-free delivery core (layer 1).
|
|
3
|
-
*
|
|
4
|
-
* Layer 1 functions take plain data — untouched body bytes plus headers —
|
|
5
|
-
* and return plain `{ status, body }` results. They reference zero
|
|
6
|
-
* request/response types (no `Request`, no framework), so any framework
|
|
7
|
-
* that can hand over the raw body bytes can use them. Layer 2
|
|
8
|
-
* (`handleInstallRequest` / `handleWebhookRequest`) adapts the Web
|
|
9
|
-
* standard onto this core; `@usequeek/app-sdk/hono` adapts Hono onto
|
|
10
|
-
* layer 2.
|
|
11
|
-
*
|
|
12
|
-
* The raw-body rule (Stripe's model:
|
|
13
|
-
* `stripe.webhooks.constructEvent(rawBody, sigHeader, secret)` —
|
|
14
|
-
* https://docs.stripe.com/webhooks: "Stripe requires the raw body of the
|
|
15
|
-
* request to perform signature verification… Any manipulation to the raw
|
|
16
|
-
* body of the request causes the verification to fail"): `rawBody` must be
|
|
17
|
-
* the exact bytes Queek signed. Never pass a parsed-then-restringified
|
|
18
|
-
* body — JSON re-serialization changes bytes (spacing, key order) and the
|
|
19
|
-
* signature will not verify.
|
|
20
|
-
*/
|
|
1
|
+
import { createLogger } from "./logger.js";
|
|
21
2
|
/** Case-insensitive header lookup across `Headers` and plain (possibly lowercased, possibly array-valued) objects. */
|
|
22
3
|
export function readHeader(headers, name) {
|
|
23
4
|
if (headers instanceof Headers) {
|
|
@@ -39,8 +20,32 @@ export function decodeBody(rawBody) {
|
|
|
39
20
|
return rawBody;
|
|
40
21
|
return new TextDecoder("utf-8").decode(rawBody);
|
|
41
22
|
}
|
|
23
|
+
let defaultLogger = null;
|
|
24
|
+
/**
|
|
25
|
+
* Surface the error a callback threw, so a 500 is never a mystery. Calls the
|
|
26
|
+
* app's `onError` when given, otherwise logs the error name and message
|
|
27
|
+
* (secrets redacted) to stderr. A throwing `onError` cannot change the
|
|
28
|
+
* response.
|
|
29
|
+
*/
|
|
30
|
+
export function reportDeliveryFailure(onError, error, failure) {
|
|
31
|
+
try {
|
|
32
|
+
if (onError) {
|
|
33
|
+
onError(error, failure);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
defaultLogger ??= createLogger({ service: "queek-app-sdk" });
|
|
37
|
+
defaultLogger.error("queek delivery callback failed; answering 500 so Queek retries", {
|
|
38
|
+
stage: failure.stage,
|
|
39
|
+
event_id: failure.eventId,
|
|
40
|
+
topic: failure.topic,
|
|
41
|
+
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Reporting must never change the HTTP answer.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
42
48
|
/** Build the layer-2 `Response` from a layer-1 result. */
|
|
43
49
|
export function toResponse(result) {
|
|
44
50
|
return Response.json(result.body, { status: result.status, headers: result.headers });
|
|
45
51
|
}
|
|
46
|
-
//# sourceMappingURL=delivery.js.map
|
package/dist/frame.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Framed-page bridge client, v1
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Framed-page bridge client, v1: the browser-safe half of the dashboard
|
|
3
|
+
* bridge. Carries NO secret and performs NO verification — it only shapes
|
|
4
|
+
* and addresses messages. The dashboard enforces origin+source binding and
|
|
5
|
+
* mints tokens; the app's server verifies them via `./server`
|
|
6
|
+
* (`verifySessionToken`).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Compatible with the v0 protocol (`ready`/`token`/`resize`/`resize-ack`/
|
|
9
|
+
* `ack`, sources `queek-app`/`queek-merchant`, exact-origin postMessage):
|
|
10
|
+
* v1 only ADDS optional fields and new message types, and both directions
|
|
11
|
+
* ignore unknown types.
|
|
12
12
|
*
|
|
13
13
|
* DOM-free by design (structural minimal types): the app passes its own
|
|
14
14
|
* window/parent in, so this module needs no DOM lib and stays import-safe
|
|
@@ -240,7 +240,7 @@ export declare function sendReady(dashboardOrigin: string, target?: EmbedPostTar
|
|
|
240
240
|
* (via `sendBridgeMessage`) clamps and drops non-finite asks.
|
|
241
241
|
*/
|
|
242
242
|
export declare function sendResize(dashboardOrigin: string, target: EmbedPostTarget | undefined, height: number): void;
|
|
243
|
-
/** Delivery confirmation
|
|
243
|
+
/** Delivery confirmation carrying no state (accepted, no further effect). */
|
|
244
244
|
export declare function sendAck(dashboardOrigin: string, target?: EmbedPostTarget): void;
|
|
245
245
|
/**
|
|
246
246
|
* Drive the dashboard title bar (heading + up to 5 secondary actions).
|
|
@@ -257,7 +257,7 @@ export declare function sendToast(dashboardOrigin: string, target: EmbedPostTarg
|
|
|
257
257
|
}): void;
|
|
258
258
|
/** Show (dirty) or hide (clean) the dashboard save bar. Bad states are dropped downstream. */
|
|
259
259
|
export declare function sendSaveBar(dashboardOrigin: string, target: EmbedPostTarget | undefined, state: SaveBarState): void;
|
|
260
|
-
/** Report an in-app move so the dashboard can mirror it in its URL
|
|
260
|
+
/** Report an in-app move so the dashboard can mirror it in its URL. */
|
|
261
261
|
export declare function sendNavigated(dashboardOrigin: string, target: EmbedPostTarget | undefined, path: string): void;
|
|
262
262
|
/**
|
|
263
263
|
* Ask the dashboard to open a dashboard path or https URL (sandbox-safe).
|
|
@@ -284,4 +284,3 @@ export declare function listenToDashboard(options: FrameBridgeOptions): () => vo
|
|
|
284
284
|
* blocked open target, non-product pick) — `sendBridgeMessage` skips null.
|
|
285
285
|
*/
|
|
286
286
|
export declare function clipOutbound(message: AppOutboundMessage): AppOutboundMessage | null;
|
|
287
|
-
//# sourceMappingURL=frame.d.ts.map
|