@usequeek/app-sdk 0.6.2 → 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.
Files changed (107) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +340 -337
  3. package/dist/app-auth.d.ts +1 -2
  4. package/dist/app-auth.js +1 -2
  5. package/dist/auth-fetch.d.ts +12 -1
  6. package/dist/auth-fetch.js +13 -2
  7. package/dist/background.d.ts +9 -1
  8. package/dist/background.js +9 -1
  9. package/dist/browser.d.ts +1 -2
  10. package/dist/browser.js +1 -2
  11. package/dist/client.d.ts +130 -7
  12. package/dist/client.js +198 -41
  13. package/dist/collected.d.ts +0 -1
  14. package/dist/collected.js +0 -1
  15. package/dist/crypto.d.ts +11 -1
  16. package/dist/crypto.js +22 -7
  17. package/dist/delivery.d.ts +18 -1
  18. package/dist/delivery.js +26 -17
  19. package/dist/frame.d.ts +0 -1
  20. package/dist/frame.js +0 -1
  21. package/dist/handoff.d.ts +0 -1
  22. package/dist/handoff.js +0 -1
  23. package/dist/hono.d.ts +0 -1
  24. package/dist/hono.js +0 -1
  25. package/dist/index.d.ts +1 -2
  26. package/dist/index.js +1 -2
  27. package/dist/install-handlers.d.ts +22 -2
  28. package/dist/install-handlers.js +37 -7
  29. package/dist/logger.d.ts +7 -1
  30. package/dist/logger.js +7 -1
  31. package/dist/merchant-schema.d.ts +1184 -336
  32. package/dist/merchant-schema.js +0 -1
  33. package/dist/proxy.d.ts +2 -3
  34. package/dist/proxy.js +4 -5
  35. package/dist/react.d.ts +9 -1
  36. package/dist/react.js +10 -2
  37. package/dist/resync.d.ts +11 -1
  38. package/dist/resync.js +18 -16
  39. package/dist/scopes.d.ts +14 -1
  40. package/dist/scopes.js +18 -17
  41. package/dist/server.d.ts +0 -1
  42. package/dist/server.js +0 -1
  43. package/dist/session.d.ts +20 -1
  44. package/dist/session.js +20 -1
  45. package/dist/signatures.d.ts +26 -1
  46. package/dist/signatures.js +29 -3
  47. package/dist/store.d.ts +6 -1
  48. package/dist/store.js +0 -1
  49. package/dist/theme.d.ts +0 -1
  50. package/dist/theme.js +0 -1
  51. package/dist/tokens.d.ts +9 -1
  52. package/dist/tokens.js +23 -47
  53. package/dist/webhooks.d.ts +28 -3
  54. package/dist/webhooks.js +25 -7
  55. package/package.json +18 -2
  56. package/dist/app-auth.d.ts.map +0 -1
  57. package/dist/app-auth.js.map +0 -1
  58. package/dist/auth-fetch.d.ts.map +0 -1
  59. package/dist/auth-fetch.js.map +0 -1
  60. package/dist/background.d.ts.map +0 -1
  61. package/dist/background.js.map +0 -1
  62. package/dist/browser.d.ts.map +0 -1
  63. package/dist/browser.js.map +0 -1
  64. package/dist/client.d.ts.map +0 -1
  65. package/dist/client.js.map +0 -1
  66. package/dist/collected.d.ts.map +0 -1
  67. package/dist/collected.js.map +0 -1
  68. package/dist/crypto.d.ts.map +0 -1
  69. package/dist/crypto.js.map +0 -1
  70. package/dist/delivery.d.ts.map +0 -1
  71. package/dist/delivery.js.map +0 -1
  72. package/dist/frame.d.ts.map +0 -1
  73. package/dist/frame.js.map +0 -1
  74. package/dist/handoff.d.ts.map +0 -1
  75. package/dist/handoff.js.map +0 -1
  76. package/dist/hono.d.ts.map +0 -1
  77. package/dist/hono.js.map +0 -1
  78. package/dist/index.d.ts.map +0 -1
  79. package/dist/index.js.map +0 -1
  80. package/dist/install-handlers.d.ts.map +0 -1
  81. package/dist/install-handlers.js.map +0 -1
  82. package/dist/logger.d.ts.map +0 -1
  83. package/dist/logger.js.map +0 -1
  84. package/dist/merchant-schema.d.ts.map +0 -1
  85. package/dist/merchant-schema.js.map +0 -1
  86. package/dist/proxy.d.ts.map +0 -1
  87. package/dist/proxy.js.map +0 -1
  88. package/dist/react.d.ts.map +0 -1
  89. package/dist/react.js.map +0 -1
  90. package/dist/resync.d.ts.map +0 -1
  91. package/dist/resync.js.map +0 -1
  92. package/dist/scopes.d.ts.map +0 -1
  93. package/dist/scopes.js.map +0 -1
  94. package/dist/server.d.ts.map +0 -1
  95. package/dist/server.js.map +0 -1
  96. package/dist/session.d.ts.map +0 -1
  97. package/dist/session.js.map +0 -1
  98. package/dist/signatures.d.ts.map +0 -1
  99. package/dist/signatures.js.map +0 -1
  100. package/dist/store.d.ts.map +0 -1
  101. package/dist/store.js.map +0 -1
  102. package/dist/theme.d.ts.map +0 -1
  103. package/dist/theme.js.map +0 -1
  104. package/dist/tokens.d.ts.map +0 -1
  105. package/dist/tokens.js.map +0 -1
  106. package/dist/webhooks.d.ts.map +0 -1
  107. package/dist/webhooks.js.map +0 -1
package/dist/client.js CHANGED
@@ -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) ?? `Queek API error ${status}`;
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
- function isRetryable(error) {
185
- return error instanceof QueekApiError && (error.isRateLimited || error.code === "network_error");
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
- function retryDelayMs(error, attempt, baseDelayMs, maxDelayMs) {
188
- if (error.isRateLimited && error.retryAfterMs !== undefined)
189
- return error.retryAfterMs;
190
- return Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
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: options.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 new QueekApiError({
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, parsed, response.headers);
355
+ throw queekApiErrorFromResponse(response.status, text === "" ? null : tryParseJson(text), response.headers);
233
356
  }
234
- return parsed;
357
+ return parseSuccessBody(response, text);
235
358
  }
236
359
  async function requestWithRetry(method, path, options = {}) {
237
- const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
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
- function tryParseJson(text) {
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
- //# sourceMappingURL=client.js.map
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
+ }
@@ -99,4 +99,3 @@ export declare function createRecord(client: QueekClient, submit: {
99
99
  idempotencyKey?: string;
100
100
  }): Promise<unknown>;
101
101
  export {};
102
- //# sourceMappingURL=collected.d.ts.map
package/dist/collected.js CHANGED
@@ -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
@@ -11,6 +11,15 @@ 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
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).");
@@ -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("Encryption key must be 32 bytes.");
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("Encryption key must be 32 bytes.");
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 (expected v1.<iv>.<ciphertext>).");
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
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
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
@@ -39,6 +39,23 @@ export interface DeliveryResult {
39
39
  export declare function readHeader(headers: CoreHeaders, name: string): string | undefined;
40
40
  /** Decode untouched body bytes to the exact string Queek signed. */
41
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;
42
60
  /** Build the layer-2 `Response` from a layer-1 result. */
43
61
  export declare function toResponse(result: DeliveryResult): Response;
44
- //# sourceMappingURL=delivery.d.ts.map
package/dist/delivery.js CHANGED
@@ -1,19 +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: `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.
16
- */
1
+ import { createLogger } from "./logger.js";
17
2
  /** Case-insensitive header lookup across `Headers` and plain (possibly lowercased, possibly array-valued) objects. */
18
3
  export function readHeader(headers, name) {
19
4
  if (headers instanceof Headers) {
@@ -35,8 +20,32 @@ export function decodeBody(rawBody) {
35
20
  return rawBody;
36
21
  return new TextDecoder("utf-8").decode(rawBody);
37
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
+ }
38
48
  /** Build the layer-2 `Response` from a layer-1 result. */
39
49
  export function toResponse(result) {
40
50
  return Response.json(result.body, { status: result.status, headers: result.headers });
41
51
  }
42
- //# sourceMappingURL=delivery.js.map
package/dist/frame.d.ts CHANGED
@@ -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
package/dist/frame.js CHANGED
@@ -728,4 +728,3 @@ export function clipOutbound(message) {
728
728
  }
729
729
  }
730
730
  }
731
- //# sourceMappingURL=frame.js.map
package/dist/handoff.d.ts CHANGED
@@ -137,4 +137,3 @@ export interface ScopesUpdateData {
137
137
  }
138
138
  export type ScopesUpdateEnvelope = HandoffEnvelope<typeof SCOPES_UPDATE_EVENT, ScopesUpdateData>;
139
139
  export type HandoffEnvelopeAny = InstallEnvelope | UninstallEnvelope | SettingsEnvelope | ResyncEnvelope | ScopesUpdateEnvelope;
140
- //# sourceMappingURL=handoff.d.ts.map
package/dist/handoff.js CHANGED
@@ -27,4 +27,3 @@ export const UNINSTALL_EVENT = "app/uninstalled";
27
27
  export const SETTINGS_EVENT = "app/settings_updated";
28
28
  export const RESYNC_EVENT = "app/resync";
29
29
  export const SCOPES_UPDATE_EVENT = "app/scopes_update";
30
- //# sourceMappingURL=handoff.js.map
package/dist/hono.d.ts CHANGED
@@ -29,4 +29,3 @@ export declare function createWebhookHandler(options: WebhookHandlerOptions): Ho
29
29
  export declare function createProxyHandler(options: ProxyRequestOptions & {
30
30
  onVerified: ProxyResponder;
31
31
  }): Hono;
32
- //# sourceMappingURL=hono.d.ts.map
package/dist/hono.js CHANGED
@@ -39,4 +39,3 @@ export function createProxyHandler(options) {
39
39
  app.get("*", (c) => handleProxyRequest(c.req.raw, requestOptions, onVerified));
40
40
  return app;
41
41
  }
42
- //# sourceMappingURL=hono.js.map
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  export { API_KEY_EXPIRED_CODE, API_KEY_REVOKED_CODE, APP_INSTALLATION_GONE_CODE, APP_INSTALLATION_PENDING_CODE, APP_JWT_SKEW_SECONDS, APP_JWT_TTL_SECONDS, APP_PRIVATE_KEY_FORMS, APP_TOKEN_REVOKED_CODE, type AppCredential, AppMintHaltedError, decodePrivateKeyInput, INSUFFICIENT_SCOPE_CODE, INVALID_CLIENT_CODE, INVALID_CLIENT_KEY_CODE, InvalidAppCredentialError, isAppTokenRevoked, isInstallationGone, isInstallationPending, isInsufficientScope, isInvalidClient, isResyncCooldown, isTokenRefusal, loadAppCredential, MAX_MINT_ATTEMPTS, MINT_BACKOFF_BASE_MS, MINT_BACKOFF_MAX_MS, RESYNC_COOLDOWN_CODE, RETRY_JITTER_MAX_MS, type SignAppJwtOptions, signAppJwt, TOKEN_VALIDITY_SKEW_SECONDS, TOO_MANY_REQUESTS_CODE, UnknownInstallationError, } from "./app-auth.js";
12
12
  export { type AuthFetchOptions, BRIDGE_TOKEN_TIMEOUT_MS, type InstalledAuth, installAuthFetch, LAUNCH_TOKEN_PARAM, readLaunchToken, stripLaunchToken, } from "./auth-fetch.js";
13
13
  export { backgroundTaskCount, type CatchupFailure, type CatchupInstallation, type CatchupOptions, type CatchupResult, type DetachOptions, detach, runInstallationCatchup, } from "./background.js";
14
- export { apiHostsFromEnv, createQueekClient, DEFAULT_API_HOSTS, devApiHostsFromEnv, InvalidApiBaseError, isWriteMethod, MERCHANT_API_PATH, type MerchantPaths, newIdempotencyKey, type OperationResponse, QueekApiError, type QueekClient, type QueekClientOptions, type QueekErrorDetails, queekApiErrorFromResponse, type RequestOptions, type ResolvedPaths, type RetryOptions, resolveApiBase, type StoreProfile, } from "./client.js";
14
+ export { apiHostsFromEnv, type CursorPage, createQueekClient, DEFAULT_API_HOSTS, DEFAULT_MAX_RETRY_AFTER_MS, DEFAULT_REQUEST_TIMEOUT_MS, devApiHostsFromEnv, InvalidApiBaseError, isWriteMethod, MERCHANT_API_PATH, type MerchantPaths, newIdempotencyKey, type OperationResponse, type PaginateOptions, paginate, QueekApiError, type QueekClient, type QueekClientOptions, type QueekErrorDetails, queekApiErrorFromResponse, type RequestOptions, type ResolvedPaths, type RetryOptions, resolveApiBase, type StoreProfile, } from "./client.js";
15
15
  export { type AlertBody, type AlertSeverity, type CollectedDefinition, type CollectedDefinitionCreate, type CollectedDefinitionUpdate, type CollectedField, collectedDefinitions, createRecord, type MerchantAlert, type SetupNotice, type SetupNoticeBody, type SetupNoticeItem, type SetupNoticeStatus, type SuccessEnvelope, sendAlert, setSetupNotice, } from "./collected.js";
16
16
  export { decryptSecret, encryptSecret, parseStoreKey, storeKeyFingerprint, } from "./crypto.js";
17
17
  export { type CoreDelivery, type CoreHeaders, type DeliveryResult, decodeBody, type InstallDelivery, readHeader, } from "./delivery.js";
@@ -27,4 +27,3 @@ export { createInstallationStore, createPostgresPool, defaultClearCachedTokenIfM
27
27
  export { applyTheme, getThemeModeFromUrl, installThemeListener, rememberThemeMode, THEME_PARAM, THEME_STORAGE_KEY, type ThemeDocument, type ThemeDocumentElement, type ThemeListenerOptions, type ThemeStorage, themeBootstrapScript, } from "./theme.js";
28
28
  export { APP_API_PATH, AppTokenProvider, type AppTokens, createAppTokenProvider, createInstallationClient, type InstallationClientOptions, mintPath, resolveAppApiBase, type TokenProviderOptions, } from "./tokens.js";
29
29
  export { handleWebhookDelivery, handleWebhookRequest, QUEEK_TOPIC_HEADER, type QueekWebhookEnvelope, type SecretResolution, type WebhookHandlerContext, type WebhookHandlerFn, type WebhookHandlerOptions, } from "./webhooks.js";
30
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  export { API_KEY_EXPIRED_CODE, API_KEY_REVOKED_CODE, APP_INSTALLATION_GONE_CODE, APP_INSTALLATION_PENDING_CODE, APP_JWT_SKEW_SECONDS, APP_JWT_TTL_SECONDS, APP_PRIVATE_KEY_FORMS, APP_TOKEN_REVOKED_CODE, AppMintHaltedError, decodePrivateKeyInput, INSUFFICIENT_SCOPE_CODE, INVALID_CLIENT_CODE, INVALID_CLIENT_KEY_CODE, InvalidAppCredentialError, isAppTokenRevoked, isInstallationGone, isInstallationPending, isInsufficientScope, isInvalidClient, isResyncCooldown, isTokenRefusal, loadAppCredential, MAX_MINT_ATTEMPTS, MINT_BACKOFF_BASE_MS, MINT_BACKOFF_MAX_MS, RESYNC_COOLDOWN_CODE, RETRY_JITTER_MAX_MS, signAppJwt, TOKEN_VALIDITY_SKEW_SECONDS, TOO_MANY_REQUESTS_CODE, UnknownInstallationError, } from "./app-auth.js";
12
12
  export { BRIDGE_TOKEN_TIMEOUT_MS, installAuthFetch, LAUNCH_TOKEN_PARAM, readLaunchToken, stripLaunchToken, } from "./auth-fetch.js";
13
13
  export { backgroundTaskCount, detach, runInstallationCatchup, } from "./background.js";
14
- export { apiHostsFromEnv, createQueekClient, DEFAULT_API_HOSTS, devApiHostsFromEnv, InvalidApiBaseError, isWriteMethod, MERCHANT_API_PATH, newIdempotencyKey, QueekApiError, queekApiErrorFromResponse, resolveApiBase, } from "./client.js";
14
+ export { apiHostsFromEnv, createQueekClient, DEFAULT_API_HOSTS, DEFAULT_MAX_RETRY_AFTER_MS, DEFAULT_REQUEST_TIMEOUT_MS, devApiHostsFromEnv, InvalidApiBaseError, isWriteMethod, MERCHANT_API_PATH, newIdempotencyKey, paginate, QueekApiError, queekApiErrorFromResponse, resolveApiBase, } from "./client.js";
15
15
  export { collectedDefinitions, createRecord, sendAlert, setSetupNotice, } from "./collected.js";
16
16
  export { decryptSecret, encryptSecret, parseStoreKey, storeKeyFingerprint, } from "./crypto.js";
17
17
  export { decodeBody, readHeader, } from "./delivery.js";
@@ -27,4 +27,3 @@ export { createInstallationStore, createPostgresPool, defaultClearCachedTokenIfM
27
27
  export { applyTheme, getThemeModeFromUrl, installThemeListener, rememberThemeMode, THEME_PARAM, THEME_STORAGE_KEY, themeBootstrapScript, } from "./theme.js";
28
28
  export { APP_API_PATH, AppTokenProvider, createAppTokenProvider, createInstallationClient, mintPath, resolveAppApiBase, } from "./tokens.js";
29
29
  export { handleWebhookDelivery, handleWebhookRequest, QUEEK_TOPIC_HEADER, } from "./webhooks.js";
30
- //# sourceMappingURL=index.js.map
@@ -1,4 +1,4 @@
1
- import { type DeliveryResult, type InstallDelivery } from "./delivery.js";
1
+ import { type DeliveryErrorHandler, type DeliveryResult, type InstallDelivery } from "./delivery.js";
2
2
  import { type InstallData, type InstallEnvelope, type ScopesUpdateEnvelope, type SettingsEnvelope, type UninstallEnvelope } from "./handoff.js";
3
3
  import type { InstallationRecord, InstallationStore } from "./store.js";
4
4
  /**
@@ -49,12 +49,18 @@ export interface InstallCallbacks {
49
49
  /** Defaults to refreshing the cached grant (dropping the cached token when it moved). */
50
50
  onScopesUpdate?: (envelope: ScopesUpdateEnvelope) => Promise<void>;
51
51
  }
52
+ /** Options for the install handoff handlers. `appSecret` and `store` are required; every callback has a safe default. */
52
53
  export interface InstallHandlerOptions extends InstallCallbacks {
53
54
  /** The app signing secret (`whsec_…`) from Queek registration. */
54
55
  appSecret: string;
55
56
  store: InstallationStore;
56
57
  nowSeconds?: number;
57
58
  maxSkewSeconds?: number;
59
+ /**
60
+ * Called with the error a callback or store write threw, before the 500
61
+ * goes back. Without it the error is logged to stderr (secrets redacted).
62
+ */
63
+ onError?: DeliveryErrorHandler;
58
64
  }
59
65
  /**
60
66
  * Build the storable installation record from install `data`. Timestamps
@@ -93,12 +99,26 @@ export declare function saveResyncedInstallation(existing: InstallationRecord, d
93
99
  * `uninstall`, or `settings`, taken from the input path's last segment)
94
100
  * from plain data — no `Request`, no framework. Same options, same
95
101
  * behaviour, same errors/status codes at every layer.
102
+ *
103
+ * Answers 401 for a missing header, stale timestamp or bad signature, 400
104
+ * for a body that is not the expected envelope, 409 for a repeated
105
+ * `webhook-id`, 404 for an unknown route, and 500 when your callback or the
106
+ * store write throws (Queek then retries).
107
+ *
108
+ * @example
109
+ * const result = await handleInstallDelivery(
110
+ * { rawBody, headers, method: "POST", path: "/queek/install" },
111
+ * { appSecret: process.env.APP_SIGNING_SECRET ?? "", store },
112
+ * );
113
+ * // reply with result.status and result.body
96
114
  */
97
115
  export declare function handleInstallDelivery(input: InstallDelivery, options: InstallHandlerOptions): Promise<DeliveryResult>;
98
116
  /**
99
117
  * Layer 2: the Web-standard wrapper, built ONLY on layer 1 — reads
100
118
  * `await request.arrayBuffer()` + headers, calls the core, builds the
101
119
  * `Response`.
120
+ *
121
+ * @example
122
+ * export const POST = (request: Request) => handleInstallRequest(request, { appSecret, store });
102
123
  */
103
124
  export declare function handleInstallRequest(request: Request, options: InstallHandlerOptions): Promise<Response>;
104
- //# sourceMappingURL=install-handlers.d.ts.map