@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.
Files changed (107) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +339 -330
  3. package/dist/app-auth.d.ts +15 -17
  4. package/dist/app-auth.js +20 -23
  5. package/dist/auth-fetch.d.ts +14 -5
  6. package/dist/auth-fetch.js +15 -6
  7. package/dist/background.d.ts +10 -2
  8. package/dist/background.js +9 -1
  9. package/dist/browser.d.ts +2 -3
  10. package/dist/browser.js +2 -3
  11. package/dist/client.d.ts +138 -16
  12. package/dist/client.js +200 -43
  13. package/dist/collected.d.ts +13 -15
  14. package/dist/collected.js +4 -5
  15. package/dist/crypto.d.ts +11 -1
  16. package/dist/crypto.js +25 -10
  17. package/dist/delivery.d.ts +22 -9
  18. package/dist/delivery.js +26 -21
  19. package/dist/frame.d.ts +11 -12
  20. package/dist/frame.js +11 -12
  21. package/dist/handoff.d.ts +35 -39
  22. package/dist/handoff.js +9 -10
  23. package/dist/hono.d.ts +3 -4
  24. package/dist/hono.js +3 -4
  25. package/dist/index.d.ts +1 -2
  26. package/dist/index.js +1 -2
  27. package/dist/install-handlers.d.ts +28 -9
  28. package/dist/install-handlers.js +48 -18
  29. package/dist/logger.d.ts +7 -1
  30. package/dist/logger.js +8 -2
  31. package/dist/merchant-schema.d.ts +15678 -6236
  32. package/dist/merchant-schema.js +0 -1
  33. package/dist/proxy.d.ts +21 -24
  34. package/dist/proxy.js +20 -22
  35. package/dist/react.d.ts +10 -2
  36. package/dist/react.js +11 -3
  37. package/dist/resync.d.ts +15 -5
  38. package/dist/resync.js +22 -20
  39. package/dist/scopes.d.ts +36 -27
  40. package/dist/scopes.js +37 -40
  41. package/dist/server.d.ts +0 -1
  42. package/dist/server.js +0 -1
  43. package/dist/session.d.ts +35 -17
  44. package/dist/session.js +32 -15
  45. package/dist/signatures.d.ts +31 -5
  46. package/dist/signatures.js +36 -9
  47. package/dist/store.d.ts +26 -26
  48. package/dist/store.js +16 -20
  49. package/dist/theme.d.ts +1 -2
  50. package/dist/theme.js +1 -2
  51. package/dist/tokens.d.ts +18 -10
  52. package/dist/tokens.js +32 -57
  53. package/dist/webhooks.d.ts +30 -6
  54. package/dist/webhooks.js +27 -10
  55. package/package.json +21 -4
  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
@@ -24,7 +24,7 @@ function parseHostList(raw, varName) {
24
24
  });
25
25
  }
26
26
  /**
27
- * Extra allowed `apiBase` hosts for test/local backends, from
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 backend),
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) ?? `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
+ }
@@ -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 (S3a): the setup notice, merchant alerts,
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 — the backend
7
- * resolves the installation from that key, never from the body — and
8
- * returns the decoded `data` payload of the success envelope.
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
- * Contracts: config/vendor_api_keys.php (Apps group + Forms proof path)
11
- * at queek_backend@c1fa1c31; request shapes below are the generated
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 (typed since bf7329af) with the create
70
- * endpoint's field vocabulary for `fields` (the generated `unknown[]`
71
- * carries no field shape).
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 (spec-correct since
92
- * bf7329af: object + additionalProperties; the writer validates pairs). A
93
- * fresh Idempotency-Key is generated per call unless the caller passes
94
- * one — no two submits ever share a key.
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 (spec-correct since
48
- * bf7329af: object + additionalProperties; the writer validates pairs). A
49
- * fresh Idempotency-Key is generated per call unless the caller passes
50
- * one — no two submits ever share a key.
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 api_key
4
- * and webhook_secret). One random 96-bit IV per value; the stored string is
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("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
@@ -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 (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.
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 (U2): the browser-safe half of the
3
- * dashboard bridge. Carries NO secret and performs NO verification — it only
4
- * shapes and addresses messages. The dashboard (queek-merchant) enforces
5
- * origin+source binding and mints tokens; the app backend verifies them via
6
- * `./server` (`verifySessionToken`).
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
- * Byte-compatible with today's protocol (`ready`/`token`/`resize`/
9
- * `resize-ack`/`ack`, sources `queek-app`/`queek-merchant`, exact-origin
10
- * postMessage): v1 only ADDS optional fields and new message types, and both
11
- * directions ignore unknown types.
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 with no state — accepted and ignored by decision. */
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 (U2b). */
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